diff --git a/KOHEN.md b/KOHEN.md index d01119f4..711672c0 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -350,18 +350,59 @@ curl http:///kodesh/status | jq '.halt_status' curl http:///kodesh/status | jq '.refuge_components' # Expect: 0 -# Pe'ah responds (the public ring) +# Pe'ah responds (the public ring) — Hebrew: the cycle has roots to reason over +curl -X POST http:///peah/ask \ + -H 'Content-Type: application/json' \ + -d '{"question":"בראשית ברא אלהים"}' +# Expect: 200. `answer` is the system's own reasoning over L1 (roots, +# gematria, te'amim groups, ki tov) — NEVER the words "Processing +# complete". `sources[0]` is Bereishit 1:1 with its Hebrew `text`. +# `processing.roots_consulted` includes "ב-ר-א". + +# Pe'ah responds — English: the system says what it cannot yet do curl -X POST http:///peah/ask \ -H 'Content-Type: application/json' \ -d '{"question":"What is bereishit?"}' -# Expect: a 200 response with `answer`, `sources`, `processing` fields +# Expect: 422, error.code "TEIKU_NO_ROOTS", type "unresolvable", with +# guidance.correction naming Architecture Step 22 and +# guidance.scope_verse carrying Bereishit 1:1 in Hebrew — the corner of +# the field, offered regardless. NOT a 200 with `sources: []`. Until the +# Korban Processor's external-model path (Step 22) is built, a question +# with no Hebrew root is answered honestly, not hollowly (tamid §4.1: +# "It does not fabricate results from partial capacity"). + +# Health now pushes a real input through the cycle (tamid §2.1 check 7) +curl http:///kodesh/health | jq '.checks' +# Expect: esh_tamid "pass" (or "shabbat"), l3_writable "pass", +# manna_present "pass" only when today's provision is actually present. ``` +**Day 6 fail modes, in order of likelihood:** +- Hebrew request returns 500 with an empty body → the chatat handler is + missing; every error must carry `code/type/message/guidance`. Builder. +- Hebrew request returns 200 with `answer: "Processing complete"` → + the cycle graded emptiness tov. Builder. +- English returns 200 → same defect. Builder. +- `esh_tamid` "fail" while pe'ah answers → the probe could not build a + ShemaContext; check L1 mount and the vav-pipeline log. + ### Day 7 — *Yom HaShevi'i* — Full integration under load ```bash -# Submit 10 questions through pe'ah -for i in $(seq 1 10); do +# Submit 10 questions through pe'ah — FIVE in Hebrew (these are the +# ones that reach L3), FIVE in English (these prove the chatat path). +# "Test query $i" is English: it carries no Hebrew root, so until +# Architecture Step 22 it is answered with 422 TEIKU_NO_ROOTS and does +# NOT write L3 — by design (BUILD_KORBAN.md:123). The L3 count below is +# expected to rise by the number of Hebrew questions, not by ten. +for q in "בראשית ברא אלהים" "את השמים ואת הארץ" "ויאמר אלהים יהי אור" "ויהי ערב ויהי בקר" "ברא אלהים"; do + curl -s -X POST http:///peah/ask \ + -H 'Content-Type: application/json' \ + -d "{\"question\":\"$q\"}" + echo + sleep 2 +done +for i in $(seq 1 5); do curl -s -X POST http:///peah/ask \ -H 'Content-Type: application/json' \ -d "{\"question\":\"Test query $i\"}" @@ -378,9 +419,18 @@ curl -X POST http:///chatzer/sanhedrin \ # Status after load curl http:///kodesh/status | jq . -# Verify L3 wrote interpretations +# Verify L3 wrote interpretations — expect >= 5 (one per Hebrew +# question; a Hebrew question that resolves to teiku lands in +# teiku_registry instead, so check both) docker run --rm postgres:17 psql "$DATABASE_URL" \ - -c "SELECT count(*) FROM interpretations;" + -c "SELECT count(*) FROM interpretations;" \ + -c "SELECT count(*) FROM teiku_registry;" + +# Manna was consumed, and NOT re-gathered per request +curl http:///kodesh/status | jq '.manna_provision' +# Expect: cycle_date = today, consumed.compute_units >= 5 (Hebrew +# cycles consume; rejected English ones consumed too — every cycle +# eats), allocated unchanged from today.json. ``` **24-hour observation period.** System runs but does not yet serve outside users. Watch logs: diff --git a/vav_pipeline/lib/vav_pipeline.ex b/vav_pipeline/lib/vav_pipeline.ex index 8b5f88fa..ed13378c 100644 --- a/vav_pipeline/lib/vav_pipeline.ex +++ b/vav_pipeline/lib/vav_pipeline.ex @@ -70,13 +70,74 @@ defmodule VavPipeline do {:waiting, :no_provision} _provision -> - # === NA'ASEH: Now process, within what was received === - BereishitCycle.execute(input, context) + # === Gather: d'var yom b'yomo === + # The file says provision exists; the LEDGER is what is + # consumed from. Load (idempotent per date), release + # yesterday's surplus if the day turned, take one compute + # unit for this cycle. Kohen Day 6: the ledger had never + # been consumed from — two live cycles left it at + # cycle_date: nil, cycle_count: 0. + case Manna.gather_for_cycle() do + :ok -> + # === NA'ASEH: Now process, within what was received === + BereishitCycle.execute(input, context) + + {:waiting, reason} -> + {:waiting, reason} + end end end end end + @doc """ + Esh tamid — push a real input through the real cycle and report + whether a result came back. + + tamid §2.1 check 7: "The processing pipeline accepts a test input and + returns a result. If the fire has gone out: re-initialize pipeline." + Until the kohen's Day-6 reading, /kodesh/health's `esh_tamid` was + `ShabbatTimer.resting?/0` — it reported `pass` while /peah/ask was + returning empty 500s. A health check that does not touch the serving + path cannot see the serving path. + + The probe: + - builds a real ShemaContext (nishma first, same as process/1) + - honours the cloud — does not run on Shabbat or in transition + - runs the full BereishitCycle on Bereishit 1:1 with Hebrew input + - does NOT consume manna and does NOT write L3 (probe: true — see + BereishitCycle.tohu/2 and erev_voker/2). The fire is checked; the + day's bread is not eaten by the check, and the oral tradition is + not padded with health pings. + + Returns {:ok, data} when the fire burns, or the same non-ok shapes + process/1 returns. + """ + @spec tamid_probe() :: cycle_result() | {:error, String.t()} + def tamid_probe do + scope = %{book: "bereishit", chapter: 1, verse: 1} + + input = %{ + source: "tamid", + verse_scope: scope, + content: "בראשית ברא אלהים", + probe: true + } + + case Shema.receive_context(scope) do + {:error, reason} -> + {:halted, :context_failed, reason} + + {:ok, context} -> + case context.cloud_state do + :camped -> BereishitCycle.execute(input, context) + :moving -> {:transition, :cloud_lifted} + {:major_transition, signal, _} -> {:transition, signal} + :cloud_not_visible -> {:waiting, :cloud_not_visible} + end + end + end + @doc """ Verify the pipeline is operational. Checks L1 integrity, cloud state, and manna provision. diff --git a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex index 32348a25..12c12ecc 100644 --- a/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex +++ b/vav_pipeline/lib/vav_pipeline/bereishit_cycle.ex @@ -14,13 +14,57 @@ defmodule VavPipeline.BereishitCycle do 3. havdalah — separate into distinguished categories 4. kriah — assign stable names 5. ki_tov — evaluate against specification - 6. erev_voker — log cycle completion, enforce boundary + 6. erev_voker — log cycle completion, enforce boundary, RECORD Latency budget (Construction spec §2.5): 5000ms total + + ## Kohen Day-6 findings (2026-09-07) and what changed here + + The first real cycles on a live pod showed three things this module + did that its unit tests never caught, because every unit test ran the + L1 fallback (no Port in the test VM) and none ran the cycle to L3: + + 1. **Real engine output crashed havdalah.** `separate_by_boundaries/2` + read `root.position`. Only `vayomer_fallback/2` ever set it; the + Shoresh Engine's `roots_activated[]` (vayakhel §3.1) has no + `position` — the engine reports a ROOT, not where in the verse it + sits. The word index is the ShemaContext's to give + (`context.verse.words`), which is what havdalah's own @doc always + said. `normalize_root/2` now anchors every root to the verse from + the context, engine-sourced or L1-sourced alike. + + 2. **Emptiness was graded tov.** English input → engine returns zero + roots → `separate_by_boundaries([], _)` returned `[[]]` → kriah + named the empty group `"group_0"` with no `:shoresh` → the + `{:lo_tov, "empty results"}` guard in Specification never saw + emptiness. The ger asking in English received HTTP 200 and the + words "Processing complete". Now: no roots → no groups → no named + results → lo_tov, and the router turns that into a chatat-shaped + answer that says what the system cannot yet do (tamid §1.2, §4.1: + "It does not fabricate results from partial capacity"). + + 3. **Nothing was ever written to L3.** `L3.Store.record_interpretation/1`, + `record_minority/1` and `record_teiku/1` had zero callers. The + cycle deliberated and logged; the oral tradition stayed empty. + PRD §5.2 — "accumulates learning in L3 through deliberate, + structured processes" — was unimplemented on the write side. + `erev_voker/2` now records: an Interpretation for a served result, + a TeikuRecord for an unresolved one, and NOTHING for a rejected + offering (BUILD_KORBAN.md:123 — rejected, not processed into empty + propositions). + + Also: `Manna.enforce_anti_hoarding/0` was called at the end of EVERY + request, which zeroes the ledger and clears its date — so a per-cycle + `consume` would have handed every request a fresh full day's + provision. The manna boundary is the DAY (Shemot 16:19 — until + MORNING), so release happens on the date roll, in + `Manna.release_at_day_boundary/0`, and consumption happens in + `VavPipeline.process/1`. """ alias VavPipeline.{Specification, CycleLog, Manna, HaltConditions, - ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient} + ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient, + Chatzotzrot} require Logger @@ -59,6 +103,10 @@ defmodule VavPipeline.BereishitCycle do Strip structure, extract raw content. This is the formless void before vayomer brings order. + + The case_ref is minted HERE, once, so every later stage — Talmudic + routing, Sanhedrin escalation, the L3 record — refers to the same + cycle by the same name. """ @spec tohu(VavPipeline.input(), VavPipeline.Shema.shema_context()) :: map() def tohu(input, _context) do @@ -66,6 +114,10 @@ defmodule VavPipeline.BereishitCycle do raw: extract_content(input), provenance: Map.get(input, :source, "unknown"), scope: Map.get(input, :verse_scope, %{}), + case_ref: generate_case_ref(), + # A tamid probe (tamid §2.1 check 7) runs the real cycle but must + # not consume the day's manna or append to the oral tradition. + probe: Map.get(input, :probe, false) == true, started_at: DateTime.utc_now() } end @@ -76,21 +128,34 @@ defmodule VavPipeline.BereishitCycle do The Shoresh Engine receives the ShemaContext — it reads te'amim, ketiv/qere, gematria as part of its analysis. - In Phase 2, this calls the Shoresh Engine via port/NIF. - Until the port is built (Step 14), this uses the L1 data - directly from the ShemaContext. + Calls the Shoresh Engine via the Erlang Port (Step 14). Falls back to + the L1 data in the ShemaContext if the Port is unavailable. + + Every root — from either source — is normalized by `normalize_root/2` + into one shape and anchored to the verse in the context. Downstream + stages, the router and the L3 record all read that one shape. """ @spec vayomer(map(), VavPipeline.Shema.shema_context()) :: map() def vayomer(state, context) do - # Step 14: Call Shoresh Engine via port. Falls back to L1 data. case ShoreshEngineClient.process(state.raw, context) do {:ok, engine_result} -> + raw_roots = get_result(engine_result, :roots_activated, "roots_activated", []) + + if raw_roots == [] do + Logger.info( + "BereishitCycle.vayomer: Shoresh Engine activated no roots from " <> + "#{inspect(String.slice(state.raw, 0, 60))} — the input carries no " <> + "Hebrew root the engine can read; ki_tov will not call this tov" + ) + end + Map.merge(state, %{ - roots_activated: get_result(engine_result, :roots_activated, "roots_activated", []), + roots_activated: Enum.map(raw_roots, &normalize_root(&1, context, :engine)), parse_tree: get_result(engine_result, :parse_tree, "parse_tree", nil), dual_readings: get_result(engine_result, :dual_readings, "dual_readings", []), teamim_parse: context.teamim_marks, ketiv_qere: context.ketiv_qere, + root_source: :engine, stage: :vayomer }) @@ -105,18 +170,22 @@ defmodule VavPipeline.BereishitCycle do Te'amim inform the separation — the text's own divisions guide havdalah. Uses the te'amim marks from the ShemaContext to determine boundaries. + + A root's place in the verse comes from the context (set by + `normalize_root/2`), never from the engine. Roots that do not occur in + the verse in scope (a Hebrew word the asker typed that is not in this + pasuk) cannot be divided by this pasuk's te'amim; they form their own + trailing group rather than being dropped or crashing the stage. """ @spec havdalah(map(), VavPipeline.Shema.shema_context()) :: map() def havdalah(utterance, context) do - # Group roots by domain based on te'amim divisions - # Te'amim melekh marks (disjunctors) create boundaries + # Te'amim melekh marks (disjunctors) create boundaries — 0-based word index melekh_positions = context.teamim_marks |> Enum.filter(fn m -> m.role == "melekh" end) |> Enum.map(fn m -> m.word_index end) |> Enum.sort() - # Separate roots into groups divided by melekh marks roots = Map.get(utterance, :roots_activated, []) groups = separate_by_boundaries(roots, melekh_positions) @@ -131,26 +200,27 @@ defmodule VavPipeline.BereishitCycle do Kriah — assign stable names to each distinction. Each separated group receives a name based on its primary root. + + A group with no roots is not named. There is nothing to call it — + naming emptiness `"group_0"` was how an empty cycle reached ki_tov + looking like a result. `Specification.evaluate/2` sees `[]` and says + lo_tov, as it always could. """ @spec kriah(map(), VavPipeline.Shema.shema_context()) :: map() def kriah(distinctions, _context) do named = distinctions |> Map.get(:separated_groups, []) + |> Enum.reject(&(&1 == [])) |> Enum.with_index() |> Enum.map(fn {group, idx} -> primary_root = List.first(group) - name = - if primary_root do - primary_root.shoresh - else - "group_#{idx}" - end - %{ - name: name, + name: primary_root.shoresh, + shoresh: primary_root.shoresh, roots: group, + positions: group |> Enum.map(& &1.position) |> Enum.reject(&is_nil/1), index: idx } end) @@ -192,14 +262,24 @@ defmodule VavPipeline.BereishitCycle do end @doc """ - Erev Voker — log cycle completion, enforce boundary. + Erev Voker — log cycle completion, enforce boundary, record. - Release any manna surplus — do not hoard into next cycle. + Order matters: + 1. log the cycle (CycleLog) + 2. release manna surplus IF the day has turned (Shemot 16:19 — + until morning; the boundary is the day, not the request) + 3. route unresolved results (Talmudic Engine → Sanhedrin) + 4. check halt conditions on the final output + 5. compose the answer and its sources from the ShemaContext + 6. record to L3 — Interpretation / TeikuRecord / nothing + + Step 6 is skipped for a tamid probe. Step 6 writes nothing for a + rejected offering: BUILD_KORBAN.md:123 — "the offering without vital + organs is rejected, not processed into empty propositions." """ @spec erev_voker(VavPipeline.cycle_result(), VavPipeline.Shema.shema_context()) :: VavPipeline.cycle_result() def erev_voker(result, context) do - # Log the cycle phase = case result do {:ok, _} -> :voker @@ -210,27 +290,422 @@ defmodule VavPipeline.BereishitCycle do CycleLog.record(result, phase) - # Enforce anti-hoarding - Manna.enforce_anti_hoarding() + # Al yoter mimenu ad boker — release surplus at the DAY boundary. + Manna.release_at_day_boundary() - # Route unresolved, then check halt conditions on final output - case result do - {:ok, data} -> - check_halt_on_output(data) + final = + case result do + {:ok, data} -> + check_halt_on_output(data) - {:unresolved, data} -> - routed = route_if_unresolved(data, context) + {:unresolved, data} -> + case route_if_unresolved(data, context) do + {:ok, routed_data} -> check_halt_on_output(routed_data) + other -> other + end - case routed do - {:ok, routed_data} -> check_halt_on_output(routed_data) - other -> other - end + other -> + other + end - other -> - other + final = compose(final, context) + record_to_l3(final, context) + final + end + + # ============================================================ + # Compose — the answer and its sources, from the context + # ============================================================ + + # The answer is the system's own reasoning over L1, stated plainly: + # which roots, where in which verse, what the te'amim divided, what + # ki_tov found. Not a model's prose. The sources are the verse(s) the + # context actually held — tamid §1.1's {book, chapter, verse, text}. + defp compose({:ok, data}, context) do + {:ok, + Map.merge(data, %{ + interpretation: Map.get(data, :interpretation) || answer_text(data, context), + sources: sources_for(data, context) + })} + end + + defp compose({:unresolved, data}, context) do + {:unresolved, + Map.merge(data, %{ + interpretation: Map.get(data, :interpretation) || teiku_text(data, context), + sources: sources_for(data, context) + })} + end + + defp compose(other, _context), do: other + + @doc false + def answer_text(data, context) do + verse = context.verse + roots = Map.get(data, :roots_activated, []) + groups = Map.get(data, :named_results, []) + + root_lines = + Enum.map(roots, fn r -> + where = + case r.positions do + [] -> "not in this verse" + ps -> "word " <> Enum.join(Enum.map(ps, &to_string/1), ", ") + end + + "#{hyphenate(r.shoresh)} (gematria #{r.gematria}, #{r.binyan || "binyan unknown"}, #{where})" + end) + + group_line = + case groups do + [] -> "" + gs -> + names = Enum.map(gs, fn g -> hyphenate(g.name) end) + " Te'amim divide them into #{length(gs)} group(s): #{Enum.join(names, " | ")}." + end + + deliberation = + case Map.get(data, :resolution) do + :talmudic -> + text = get_in(data, [:talmudic_interpretation, "interpretation", "text"]) || "" + " Talmudic Engine: #{text}" + + :sanhedrin -> + pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || "" + " Sanhedrin majority: #{pos}" + + _ -> + "" + end + + "Roots activated in #{verse.ref}: #{Enum.join(root_lines, "; ")}." <> + group_line <> + deliberation <> + " Ki tov: the output aligns with the specification; ketiv/qere readings preserved." + end + + @doc false + def teiku_text(data, context) do + verse = context.verse + roots = Map.get(data, :roots_activated, []) |> Enum.map(&hyphenate(&1.shoresh)) + + base = "Teiku on #{verse.ref} — roots #{inspect(roots)} could not be resolved against the specification." + + case Map.get(data, :talmudic_interpretation) do + %{"interpretation" => %{"text" => t}} when is_binary(t) and t != "" -> base <> " Talmudic Engine: #{t}" + _ -> base end end + defp sources_for(data, context) do + verse = context.verse + + primary = %{ + book: verse.book, + chapter: verse.chapter, + verse: verse.verse, + ref: verse.ref, + text: verse.text_hebrew + } + + talmudic_verses = + case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do + vs when is_list(vs) -> Enum.map(vs, fn v -> %{ref: to_string(v)} end) + _ -> [] + end + + [primary | talmudic_verses] + end + + # ============================================================ + # L3 — the oral tradition is written here + # ============================================================ + + # Devarim 17:9 — v'darashta v'higidu lekha. The inquiry is recorded or + # it did not accumulate. Never crashes the cycle: a served answer is + # not turned into a 500 because the record failed — but the failure + # is loud (Logger.error + chatzotzrot :assembly_leaders), because an + # oral tradition that silently stops growing is the Day-6 finding + # all over again. + defp record_to_l3(final, context) do + cond do + probe?(final) -> + :skipped_probe + + not l3_available?() -> + Logger.info("BereishitCycle: L3 not available — cycle not recorded (dev/test without Repo)") + :skipped_no_repo + + true -> + case l3_attrs(final, context) do + :none -> + :none + + {:interpretation, attrs, minority} -> + # A validation failure comes back as {:error, changeset}; a + # database that is DOWN comes back as a raise + # (DBConnection.ConnectionError, Postgrex.Error). Both are + # the same fact for the cycle — this record did not land — + # and neither may turn a served answer into a 500. + safely(attrs.case_ref, :interpretation, fn -> + case VavPipeline.L3.Store.record_interpretation(attrs) do + {:ok, interp} -> + Logger.info("L3: interpretation #{interp.id} recorded for #{attrs.case_ref} (#{attrs.status})") + maybe_record_minority(interp.id, minority) + {:ok, interp} + + {:error, why} -> + l3_failure(attrs.case_ref, :interpretation, why) + end + end) + + {:teiku, attrs} -> + safely(attrs.case_ref, :teiku, fn -> + case VavPipeline.L3.Store.record_teiku(attrs) do + {:ok, rec} -> + Logger.info("L3: teiku #{rec.id} registered for #{attrs.case_ref}") + {:ok, rec} + + {:error, why} -> + l3_failure(attrs.case_ref, :teiku, why) + end + end) + end + end + end + + defp safely(case_ref, kind, fun) do + fun.() + rescue + e -> l3_failure(case_ref, kind, e) + catch + :exit, reason -> l3_failure(case_ref, kind, {:exit, reason}) + end + + defp maybe_record_minority(_id, nil), do: :none + + defp maybe_record_minority(interpretation_id, minority) do + attrs = Map.put(minority, :interpretation_id, interpretation_id) + + safely("interpretation #{interpretation_id}", :minority, fn -> + case VavPipeline.L3.Store.record_minority(attrs) do + {:ok, op} -> Logger.info("L3: minority opinion #{op.id} preserved") + {:error, why} -> l3_failure("interpretation #{interpretation_id}", :minority, why) + end + end) + end + + defp l3_failure(case_ref, kind, why) do + Logger.error("L3_WRITE_FAILED: #{kind} for #{case_ref} — #{inspect(why)}") + + if Process.whereis(Chatzotzrot) do + Chatzotzrot.signal(:assembly_leaders, + reason: "L3 write failed (#{kind}) for #{case_ref} — the oral tradition did not record this cycle" + ) + end + + {:error, why} + end + + defp l3_available?, do: Process.whereis(VavPipeline.Repo) != nil + + defp probe?({_, data}) when is_map(data), do: Map.get(data, :probe, false) == true + defp probe?(_), do: false + + @doc """ + Build the L3 record for a finished cycle. Pure — no I/O — so it is + testable without PostgreSQL. + + Returns one of: + {:interpretation, attrs, minority_attrs | nil} — a served result + {:teiku, attrs} — an unresolved one + :none — rejected / halted: + nothing to record + """ + @spec l3_attrs(VavPipeline.cycle_result(), map()) :: + {:interpretation, map(), map() | nil} | {:teiku, map()} | :none + def l3_attrs({:ok, data}, context) do + verse = context.verse + roots = Map.get(data, :roots_activated, []) + scope_ref = verse_ref_string(verse) + + base = %{ + case_ref: Map.get(data, :case_ref, generate_case_ref()), + interpretation: Map.get(data, :interpretation) || answer_text(data, context), + status: "accepted", + torah_references: [scope_ref], + roots_involved: Enum.map(roots, & &1.shoresh), + shema_context_hash: shema_context_hash(context), + coupling_trace: coupling_trace(roots, verse) + } + + case Map.get(data, :resolution) do + :sanhedrin -> + decision = data.sanhedrin_decision + majority = Map.get(decision, :majority, %{}) + minority = Map.get(decision, :minority, %{}) + + attrs = + Map.merge(base, %{ + reasoning: Map.get(majority, :reasoning) || Map.get(majority, :position) || "Sanhedrin majority", + torah_references: Enum.uniq([scope_ref | to_string_list(Map.get(majority, :torah_refs, []))]), + panel_size: max(Map.get(decision, :panel_size, 1), 1), + vote_count_for: Map.get(majority, :vote_count, 1), + vote_count_against: Map.get(minority, :vote_count, 0), + processing_component: "sanhedrin" + }) + + minority_attrs = + case Map.get(minority, :reasoning) do + r when is_binary(r) and r != "" -> + %{ + dissenting_reasoning: r, + torah_references: to_string_list(Map.get(minority, :torah_refs, [])), + grounding_type: nil + } + + _ -> + nil + end + + {:interpretation, attrs, minority_attrs} + + :talmudic -> + interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{} + + attrs = + Map.merge(base, %{ + reasoning: Map.get(interp, "text") || "Talmudic Engine resolved the case", + torah_references: Enum.uniq([scope_ref | to_string_list(Map.get(interp, "verses", []))]), + panel_size: 1, + vote_count_for: 1, + vote_count_against: 0, + processing_component: "talmudic_engine" + }) + + {:interpretation, attrs, nil} + + _ -> + attrs = + Map.merge(base, %{ + reasoning: single_judge_reasoning(data, context), + panel_size: 1, + vote_count_for: 1, + vote_count_against: 0, + processing_component: "bereishit_cycle" + }) + + {:interpretation, attrs, nil} + end + end + + def l3_attrs({:unresolved, data}, context) do + verse = context.verse + scope_ref = verse_ref_string(verse) + roots = Map.get(data, :roots_activated, []) |> Enum.map(& &1.shoresh) + + interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{} + challenge = get_in(data, [:talmudic_interpretation, "challenge"]) || %{} + decision = Map.get(data, :sanhedrin_decision) || %{} + + {a, refs_a} = + cond do + is_binary(Map.get(interp, "text")) and Map.get(interp, "text") != "" -> + {Map.get(interp, "text"), to_string_list(Map.get(interp, "verses", []))} + + is_map(Map.get(decision, :majority)) -> + m = decision.majority + {Map.get(m, :position) || "Sanhedrin majority", to_string_list(Map.get(m, :torah_refs, []))} + + true -> + {"Bereishit cycle could not ground roots #{inspect(roots)} in the specification", []} + end + + {b, refs_b} = + cond do + is_binary(Map.get(challenge, "kushya")) and Map.get(challenge, "kushya") != "" -> + {Map.get(challenge, "kushya"), []} + + is_map(Map.get(decision, :minority)) and (Map.get(decision.minority, :position) || "") != "" -> + m = decision.minority + {Map.get(m, :position), to_string_list(Map.get(m, :torah_refs, []))} + + true -> + {"No second reading was produced; the tension is between the roots and L1 grounding", []} + end + + attrs = %{ + case_ref: Map.get(data, :case_ref, generate_case_ref()), + tension_description: + "Unresolved cycle on #{verse.ref} — roots #{inspect(roots)}; " <> + "evaluation #{inspect(Map.get(data, :evaluation, :teiku))}" <> + routing_note(data), + interpretation_a: a, + interpretation_b: b, + torah_refs_a: Enum.uniq([scope_ref | refs_a]), + torah_refs_b: refs_b, + challenge_sequence: + case Map.get(challenge, "kushya") do + k when is_binary(k) and k != "" -> [k] + _ -> [] + end + } + + {:teiku, attrs} + end + + def l3_attrs(_rejected_or_halted, _context), do: :none + + defp routing_note(data) do + cond do + Map.has_key?(data, :routing_error) -> "; Talmudic routing failed: #{data.routing_error}" + Map.has_key?(data, :escalation_error) -> "; Sanhedrin escalation failed: #{data.escalation_error}" + true -> "" + end + end + + defp single_judge_reasoning(data, context) do + roots = Map.get(data, :roots_activated, []) + groups = Map.get(data, :named_results, []) + source = Map.get(data, :root_source, :engine) + + "#{length(roots)} root(s) activated by #{source_label(source)} from " <> + "#{inspect(String.slice(Map.get(data, :raw, ""), 0, 120))} within #{context.verse.ref}; " <> + "te'amim separated #{length(groups)} group(s) at melekh boundaries " <> + "#{inspect(Map.get(data, :separation_boundaries, []))}; ki_tov: tov " <> + "(#{length(context.ketiv_qere)} ketiv/qere position(s), all preserved). " <> + "Single-judge minchah; no Sanhedrin deliberation." + end + + defp source_label(:engine), do: "the Shoresh Engine" + defp source_label(:l1), do: "L1 fallback (Shoresh Engine unavailable)" + defp source_label(other), do: to_string(other) + + # coupled(root(ברא), verse(bereishit, 1, 1)) — the L1-anchored shape + # VavPipeline.L3.CouplingTrace accepts. Only roots that actually occur + # in the verse are coupled to it; a root the asker typed that is not + # in this pasuk has no L1 anchor here and is not written as if it did. + defp coupling_trace(roots, verse) do + book = verse.book |> to_string() |> String.downcase() + + roots + |> Enum.filter(fn r -> r.positions != [] end) + |> Enum.map(fn r -> "coupled(root(#{r.shoresh}), verse(#{book}, #{verse.chapter}, #{verse.verse}))" end) + |> Enum.uniq() + end + + defp shema_context_hash(context) do + v = context.verse + + :crypto.hash(:sha256, "#{v.ref}|#{v.text_hebrew}|#{Map.get(context, :doorpost_hash, "")}") + |> Base.encode16(case: :lower) + end + + defp verse_ref_string(verse), do: verse.ref || "#{verse.book} #{verse.chapter}:#{verse.verse}" + + defp to_string_list(list) when is_list(list), do: Enum.map(list, &to_string/1) + defp to_string_list(_), do: [] + # --- Halt Check --- defp check_halt_on_output(data) do @@ -246,15 +721,10 @@ defmodule VavPipeline.BereishitCycle do @doc false defp route_if_unresolved(data, context) do - # Extract roots for the Talmudic Engine query roots = Map.get(data, :roots_activated, []) - context_roots = Enum.map(roots, fn - %{shoresh: s} when is_binary(s) -> s - %{"shoresh" => s} when is_list(s) -> Enum.join(s, "-") - _ -> "unknown" - end) + context_roots = Enum.map(roots, & &1.shoresh) - case_ref = "cycle-#{:erlang.system_time(:millisecond)}" + case_ref = Map.get(data, :case_ref, generate_case_ref()) talmudic_context = %{ context_roots: context_roots, @@ -272,12 +742,10 @@ defmodule VavPipeline.BereishitCycle do })} {:ok, %{"status" => "teiku"}} -> - # Still unresolved — escalate to Sanhedrin Logger.info("BereishitCycle: Talmudic Engine returned teiku, escalating to Sanhedrin") escalate_to_sanhedrin(data, context, case_ref, context_roots) {:ok, interpretation} -> - # Challenged or other status — include and return {:unresolved, Map.merge(data, %{ talmudic_interpretation: interpretation, stage: :erev_voker @@ -342,19 +810,107 @@ defmodule VavPipeline.BereishitCycle do gematria: Map.get(context.root_gematria, w.shoresh, 0) } end) + |> Enum.map(&normalize_root(&1, context, :l1)) Map.merge(state, %{ roots_activated: roots, teamim_parse: context.teamim_marks, ketiv_qere: context.ketiv_qere, + root_source: :l1, stage: :vayomer }) end - # --- Private helpers --- + # ============================================================ + # Root normalization — one shape, anchored to the verse + # ============================================================ + + @doc """ + Normalize a root from either source into the cycle's one root shape: + + %{ + shoresh: "ברא", # Hebrew consonantal root + shoresh_letters: ["bet", ...], # transliterated (engine) or [] + gematria: 203, + binyan: "Qal" | nil, + domain: "unknown" | nil, + torah_refs: [...], + positions: [2], # 1-based word positions IN THE VERSE + position: 2 | nil, # first of those, or nil + source: :engine | :l1, + engine: %{...} # the raw engine map, kept for audit + } + + `positions` comes from `context.verse.words` — the ShemaContext, not + the engine. vayakhel §3.1's `roots_activated[]` has no position field; + the engine reports WHAT root, the Torah text reports WHERE. + """ + @spec normalize_root(map(), map(), :engine | :l1) :: map() + def normalize_root(root, context, source) do + shoresh = + Map.get(root, :shoresh) || + Map.get(root, "shoresh_hebrew") || + letters_to_hebrew(Map.get(root, "shoresh")) + + positions = + case shoresh do + nil -> [] + s -> verse_positions_for(s, context) + end + + given_position = Map.get(root, :position) + + %{ + shoresh: shoresh, + shoresh_letters: Map.get(root, "shoresh") || Map.get(root, :shoresh_letters) || [], + gematria: Map.get(root, :gematria) || Map.get(root, "gematria_value") || 0, + binyan: Map.get(root, :binyan) || Map.get(root, "binyan"), + domain: Map.get(root, :domain) || Map.get(root, "domain"), + torah_refs: Map.get(root, :torah_refs) || Map.get(root, "torah_refs") || [], + word: Map.get(root, :word), + morph: Map.get(root, :morph), + positions: positions, + position: given_position || List.first(positions), + source: source, + engine: if(source == :engine, do: root, else: nil) + } + end + + defp verse_positions_for(shoresh, context) do + words = get_in(context, [:verse, :words]) || [] + + words + |> Enum.filter(fn w -> w.shoresh == shoresh end) + |> Enum.map(& &1.position) + |> Enum.sort() + end + + # The engine's "shoresh" is a list of consonant NAMES ("bet","resh", + # "aleph"). shoresh_hebrew is the authoritative Hebrew form and is + # always present in practice; this is a last resort that never + # fabricates — unknown names return nil, not a wrong root. + @consonant_names %{ + "aleph" => "א", "bet" => "ב", "gimel" => "ג", "dalet" => "ד", "he" => "ה", + "vav" => "ו", "zayin" => "ז", "chet" => "ח", "tet" => "ט", "yod" => "י", + "kaf" => "כ", "lamed" => "ל", "mem" => "מ", "nun" => "נ", "samekh" => "ס", + "ayin" => "ע", "pe" => "פ", "tsadi" => "צ", "qof" => "ק", "resh" => "ר", + "shin" => "ש", "tav" => "ת" + } + + defp letters_to_hebrew(nil), do: nil + + defp letters_to_hebrew(letters) when is_list(letters) do + mapped = Enum.map(letters, fn l -> Map.get(@consonant_names, String.downcase(to_string(l))) end) + if Enum.any?(mapped, &is_nil/1), do: nil, else: Enum.join(mapped) + end + + defp letters_to_hebrew(_), do: nil + + # ============================================================ + # Private helpers + # ============================================================ # Get a value from a map that may have atom or string keys - # (atom keys from L1 fallback, string keys from JSON engine response) defp get_result(map, atom_key, string_key, default) do Map.get(map, atom_key, Map.get(map, string_key, default)) end @@ -363,27 +919,54 @@ defmodule VavPipeline.BereishitCycle do Map.get(input, :content, "") |> String.trim() end - # Separate roots into groups based on melekh boundary positions - defp separate_by_boundaries([], _boundaries), do: [[]] - defp separate_by_boundaries(roots, []), do: [roots] + defp generate_case_ref do + "cycle-#{:erlang.system_time(:millisecond)}-" <> + (:crypto.strong_rand_bytes(3) |> Base.encode16(case: :lower)) + end + + @doc false + def hyphenate(nil), do: "" + def hyphenate(s) when is_binary(s), do: s |> String.graphemes() |> Enum.join("-") + + # Separate roots into groups based on melekh boundary positions. + # + # No roots → no groups. NOT [[]]: an empty group would be "named" by + # kriah and graded by ki_tov as if something had been said. + defp separate_by_boundaries([], _boundaries), do: [] defp separate_by_boundaries(roots, boundaries) do - # Sort boundaries and use them to split the root list + # Roots the verse places, in verse order; roots it does not place, apart. + {placed, unplaced} = Enum.split_with(roots, fn r -> is_integer(r.position) end) + placed = Enum.sort_by(placed, & &1.position) + sorted_boundaries = Enum.sort(boundaries) - {groups, current} = - Enum.reduce(roots, {[], []}, fn root, {groups, current} -> - pos = root.position - 1 + placed_groups = + case {placed, sorted_boundaries} do + {[], _} -> + [] - if pos in sorted_boundaries and current != [] do - # Boundary hit — close current group, start new one with this root - {groups ++ [current], [root]} - else - {groups, current ++ [root]} - end - end) + {ps, []} -> + [ps] - # Don't forget the last group - groups ++ [current] + {ps, bs} -> + {groups, current} = + Enum.reduce(ps, {[], []}, fn root, {groups, current} -> + pos = root.position - 1 + + if pos in bs and current != [] do + {groups ++ [current], [root]} + else + {groups, current ++ [root]} + end + end) + + groups ++ [current] + end + + case unplaced do + [] -> placed_groups + us -> placed_groups ++ [us] + end end end diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index e91bf6d1..1e2b11e4 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -16,6 +16,18 @@ defmodule VavPipeline.BigdeiKodesh.Router do use Plug.Router + # tamid §1.2 — "Every error is a chatat protocol — it restores, it + # does not punish." Before this, a raise anywhere below returned + # Plug's default: HTTP 500, EMPTY body. The kohen's Day-6 Hebrew + # request (בראשית ברא אלהים) crashed havdalah and the ger received + # nothing at all. Plug.ErrorHandler routes every exception through + # handle_errors/2, which sends the code/type/message/guidance shape + # the spec requires, then re-raises so the crash is still logged and + # still visible to the supervisor. + use Plug.ErrorHandler + + require Logger + # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger # replaces Plug.Logger; sanitized paths on /peah/* honor the # no-tracking promise made in /peah/ask's own response body. @@ -290,10 +302,18 @@ defmodule VavPipeline.BigdeiKodesh.Router do disclosure: disclosure() }) - {:waiting, reason} -> - code = if reason == :no_provision, do: "MANNA_DEPLETED", else: "CLOUD_MOVING" + {:rejected, reason} -> + reject_response(conn, reason, scope) - send_error(conn, 503, code, %{ + {:waiting, reason} when reason in [:no_provision, :provision_exhausted] -> + send_error(conn, 503, "MANNA_DEPLETED", %{ + issue: "System waiting — #{reason}", + correction: "Retry later — the next provision is written by the kohen at the day boundary", + resubmit: true + }) + + {:waiting, reason} -> + send_error(conn, 503, "CLOUD_MOVING", %{ issue: "System waiting — #{reason}", correction: "Retry later", resubmit: true @@ -444,26 +464,78 @@ defmodule VavPipeline.BigdeiKodesh.Router do _ -> "fail" end + # Kohen Day 4: {:ok, :no_provision} matched {:ok, _} and read "pass". + # Manna absent is not manna present. manna_check = case VavPipeline.Manna.check() do + {:ok, :no_provision} -> "fail" {:ok, _} -> "pass" _ -> "fail" end - shabbat_check = if VavPipeline.ShabbatTimer.resting?(), do: "shabbat", else: "pass" + # tamid §1.1 lists l3_writable. Repo up AND the database answers. + # In a dev/test VM without a Repo this is "absent", which the + # overall grade counts as a failure — a vessel that cannot reach its + # oral tradition is not healthy, whatever the reason. + l3_check = + if Process.whereis(VavPipeline.Repo) do + try do + case Ecto.Adapters.SQL.query(VavPipeline.Repo, "SELECT 1", []) do + {:ok, _} -> "pass" + _ -> "fail" + end + rescue + _ -> "fail" + catch + :exit, _ -> "fail" + end + else + "absent" + end + + shabbat? = VavPipeline.ShabbatTimer.resting?() + + # tamid §2.1 check 7 — the fire is verified by pushing a test input + # through the pipeline, not by asking the calendar. On Shabbat the + # fire is banked, not out: report "shabbat" and do not run the cycle. + esh_tamid = + cond do + shabbat? -> + "shabbat" + + true -> + case VavPipeline.tamid_probe() do + {:ok, data} -> + if Map.get(data, :roots_activated, []) != [], do: "pass", else: "fail" + + {:unresolved, _} -> + # A result came back — the fire burns — but it did not resolve. + "teiku" + + {:waiting, _} -> + "waiting" + + {:transition, _} -> + "transition" + + _ -> + "fail" + end + end checks = %{ l1_hash: l1_check, doorpost_hash: doorpost_check, cloud_readable: cloud_check, manna_present: manna_check, + l3_writable: l3_check, halt_monitor_alive: halt_check, - esh_tamid: shabbat_check + esh_tamid: esh_tamid } - failed = Enum.count(checks, fn {_k, v} -> v == "fail" end) + failed = Enum.count(checks, fn {_k, v} -> v in ["fail", "absent"] end) overall = cond do - shabbat_check == "shabbat" -> "shabbat" + shabbat? -> "shabbat" failed > 1 -> "critical" failed == 1 -> "degraded" true -> "healthy" @@ -491,6 +563,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do _ -> "failed" end }, + manna_provision: manna_provision_status(), refuge_components: VavPipeline.AreiMiklat.list_refugees() |> length(), halt_status: case VavPipeline.HaltMonitor.status() do :running -> "running" @@ -540,6 +613,116 @@ defmodule VavPipeline.BigdeiKodesh.Router do } end + @doc false + @impl Plug.ErrorHandler + def handle_errors(conn, %{kind: kind, reason: reason, stack: stack}) do + status = conn.status || 500 + message = exception_message(kind, reason) + + Logger.error( + "CHATAT_UNEXPECTED on #{conn.method} #{conn.request_path} — #{message}\n" <> + Exception.format_stacktrace(stack) + ) + + # tamid §3.8: a component failing on the serving path is HIGH — + # chatzotzrot :assembly_leaders. Whereis-guarded for bare tests. + if Process.whereis(VavPipeline.Chatzotzrot) do + VavPipeline.Chatzotzrot.signal(:assembly_leaders, + reason: "Serving-path exception on #{conn.request_path}: #{message}" + ) + end + + code = if status == 400, do: "KIYOR_MALFORMED_REQUEST", else: "CHATAT_UNEXPECTED" + + send_json(conn, status, %{ + error: %{ + code: code, + type: error_type(code), + message: + if(status == 400, + do: "The request could not be read.", + else: "The system failed while serving this request. The failure is recorded; the fault is the system's, not yours." + ), + guidance: %{ + issue: message, + correction: + if(status == 400, + do: "Send a JSON body — {\"question\": \"...\"} — with Content-Type: application/json.", + else: "Retry. If it recurs, the kohanim have been signalled and will find it in the audit log." + ), + resubmit: true + } + } + }) + end + + defp exception_message(:error, %{__exception__: true} = e), do: "#{inspect(e.__struct__)}: #{Exception.message(e)}" + defp exception_message(kind, reason), do: "#{kind}: #{inspect(reason)}" + + # A rejected offering — {:lo_tov, reason} from ki_tov. Not a crash and + # not a 200 that says nothing: the system says what it could not do. + # + # The empty-results case is the ger asking in a language the Shoresh + # Engine cannot read roots from (kohen Day 6, defect 2). The honest + # answer today is that the path serving non-Hebrew questions — Korban + # Processor → external model, Architecture Step 22 — is not yet open, + # and the system does not fabricate from partial capacity (tamid + # §4.1). What it CAN give without inventing anything is the corner of + # the field itself: the verse in scope, in Hebrew, as a source. So it + # does. PRD §2.4 — no gatekeeping; this is not "come back when you + # know Hebrew", it is "here is what exists, and here is what does not + # yet". + defp reject_response(conn, reason, scope) when is_binary(reason) do + if String.starts_with?(reason, "empty results") do + send_json(conn, 422, %{ + error: %{ + code: "TEIKU_NO_ROOTS", + type: error_type("TEIKU_NO_ROOTS"), + message: + "The Shoresh Engine found no Hebrew root in this question, so there is nothing " <> + "for the cycle to reason over. The system will not call an empty result an answer.", + guidance: %{ + issue: reason, + correction: + "Ask with Hebrew text, or name a verse (for example \"bereishit 1:1\"). " <> + "Questions in other languages need the Korban Processor's external-model " <> + "path (Architecture Step 22), which is not yet open. Until it is, the system " <> + "says so rather than fabricating an answer.", + resubmit: true, + scope_verse: scope_verse(scope) + } + }, + disclosure: disclosure() + }) + else + send_json(conn, 422, %{ + error: %{ + code: "LO_TOV", + type: error_type("LO_TOV"), + message: "The cycle produced output that does not align with the specification.", + guidance: %{ + issue: reason, + correction: "Rephrase, or narrow the scope to a single verse.", + resubmit: true + } + }, + disclosure: disclosure() + }) + end + end + + # The verse in scope — the pe'ah corner the ger receives regardless. + defp scope_verse(nil), do: nil + + defp scope_verse(%{book: b, chapter: c, verse: v}) do + case VavPipeline.L1Reader.read_verse(b, c, v) do + {:ok, verse} -> %{book: verse.book, chapter: verse.chapter, verse: verse.verse, ref: verse.ref, text: verse.text_hebrew} + _ -> %{book: b, chapter: c, verse: v} + end + end + + defp scope_verse(_), do: nil + defp send_error(conn, status, code, guidance) do send_json(conn, status, %{ error: %{ @@ -557,6 +740,9 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp error_type("CLOUD" <> _), do: "system_transition" defp error_type("HALT" <> _), do: "cardinal_sin_detected" defp error_type("UNAUTHORIZED"), do: "authentication_required" + defp error_type("TEIKU" <> _), do: "unresolvable" + defp error_type("LO_TOV"), do: "specification_mismatch" + defp error_type("CHATAT" <> _), do: "correction_needed" defp error_type(_), do: "error" # ?since=ISO8601 → DateTime, or nil for "all time". @@ -608,9 +794,13 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp format_sources(_), do: [] + # tamid §1.1 shows roots_consulted as "bet-resh-aleph"; the kohen's + # Day-6 proof asks for ב-ר-א. Letter-hyphenated Hebrew satisfies the + # shape, and the transliteration rides alongside when the engine + # supplied it. defp format_roots(roots) when is_list(roots) do Enum.map(roots, fn - %{shoresh: s} -> s + %{shoresh: s} when is_binary(s) -> VavPipeline.BereishitCycle.hyphenate(s) r when is_binary(r) -> r _ -> nil end) @@ -625,6 +815,27 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp format_cloud(:cloud_not_visible), do: "not_visible" defp format_cloud(other), do: inspect(other) + defp manna_provision_status do + if Process.whereis(VavPipeline.MannaLedger) do + ledger = VavPipeline.MannaLedger.ledger() + + remaining = + Map.new(ledger.allocated, fn {k, allocated} -> + {k, max(allocated - Map.get(ledger.consumed, k, 0), 0)} + end) + + %{ + cycle_date: ledger.cycle_date, + cycle_count: ledger.cycle_count, + allocated: ledger.allocated, + consumed: ledger.consumed, + remaining: remaining + } + else + %{cycle_date: nil, note: "ledger not running"} + end + end + defp process_status(module) do if Process.whereis(module), do: "healthy", else: "not_running" end @@ -638,6 +849,10 @@ defmodule VavPipeline.BigdeiKodesh.Router do }) end + defp handle_pipeline_error(conn, {:rejected, reason}) do + reject_response(conn, reason, nil) + end + defp handle_pipeline_error(conn, {:transition, _}) do send_error(conn, 503, "CLOUD_MOVING", %{ issue: "System in transition", diff --git a/vav_pipeline/lib/vav_pipeline/manna.ex b/vav_pipeline/lib/vav_pipeline/manna.ex index 7267a0f6..fc2388f5 100644 --- a/vav_pipeline/lib/vav_pipeline/manna.ex +++ b/vav_pipeline/lib/vav_pipeline/manna.ex @@ -119,6 +119,113 @@ defmodule VavPipeline.Manna do end end + @doc """ + Release surplus if — and only if — the day has turned since the ledger + loaded its provision. + + Shemot 16:19 — *al yoter mimenu ad boker*, "let no one leave any of it + until MORNING." The manna boundary is the day. It is not the request: + `MannaLedger.enforce_cycle_boundary/0` zeroes the allocation AND + clears `cycle_date`, so calling it after every request (as + `BereishitCycle.erev_voker/2` did until the kohen's Day-6 reading) + would have re-gathered a full day's provision on the next request — + the opposite of anti-hoarding. Infinite manna, one request at a time. + + Idempotent. Safe to call at the start of every cycle and at the end. + Returns :released, :not_a_boundary, or :no_ledger. + """ + @spec release_at_day_boundary() :: :released | :not_a_boundary | :no_ledger + def release_at_day_boundary do + require Logger + + if Process.whereis(VavPipeline.MannaLedger) do + ledger = VavPipeline.MannaLedger.ledger() + today = Date.utc_today() |> Date.to_iso8601() + + if ledger.cycle_date != nil and ledger.cycle_date != today do + Logger.info( + "Manna: day boundary crossed (#{ledger.cycle_date} → #{today}) — " <> + "releasing yesterday's surplus before gathering today's" + ) + + enforce_anti_hoarding() + :released + else + :not_a_boundary + end + else + :no_ledger + end + end + + @doc """ + Gather for one cycle: load today's provision into the ledger (idempotent + per date) and consume one compute unit for this cycle. + + Shemot 16:4 — *d'var yom b'yomo*, the matter of a day in its day. This + is the call that was missing: the ledger existed, was unit-tested, and + was never consumed from, so after two live cycles it still read + `cycle_date: nil, cycle_count: 0` (kohen, Day 6). + + Returns :ok, or {:waiting, :no_provision | :provision_exhausted} — the + same shapes `VavPipeline.process/1` already returns for the file-level + check, so the router maps both to MANNA_DEPLETED. + """ + @spec gather_for_cycle() :: :ok | {:waiting, :no_provision | :provision_exhausted} + def gather_for_cycle do + require Logger + + if Process.whereis(VavPipeline.MannaLedger) do + release_at_day_boundary() + + case VavPipeline.MannaLedger.load_provision() do + {:ok, :no_provision} -> + {:waiting, :no_provision} + + {:error, reason} -> + Logger.warning("Manna: provision unreadable — #{inspect(reason)}; waiting") + {:waiting, :no_provision} + + {:ok, :already_loaded} -> + # The ledger pins cycle_date = today even when it found NO + # provision (allocated = all zeros), and then answers + # :already_loaded for the rest of the day. So a provision the + # kohen writes AFTER the first request of the day would never + # be gathered — the file check in VavPipeline.process/1 says + # "present" while the ledger says "exhausted". The manna that + # arrives late is still today's manna: if the ledger holds + # nothing for today, release the empty day and gather again. + if ledger_empty_for_today?() do + require Logger + Logger.info("Manna: ledger held an empty provision for today and a provision file is now present — re-gathering") + VavPipeline.MannaLedger.enforce_cycle_boundary() + VavPipeline.MannaLedger.load_provision() + end + + consume_one() + + {:ok, :loaded} -> + consume_one() + end + else + # No ledger process (bare unit tests) — nothing to gather from. + :ok + end + end + + defp consume_one do + case VavPipeline.MannaLedger.consume(:compute_units, 1) do + {:ok, _remaining} -> :ok + {:error, :provision_exhausted} -> {:waiting, :provision_exhausted} + {:error, :no_provision} -> {:waiting, :no_provision} + end + end + + defp ledger_empty_for_today? do + ledger = VavPipeline.MannaLedger.ledger() + ledger.allocated |> Map.values() |> Enum.all?(&(&1 == 0)) + end + @doc """ Check if today is eligible for double portion (Friday). """ diff --git a/vav_pipeline/lib/vav_pipeline/specification.ex b/vav_pipeline/lib/vav_pipeline/specification.ex index e3c475ee..f08dd30d 100644 --- a/vav_pipeline/lib/vav_pipeline/specification.ex +++ b/vav_pipeline/lib/vav_pipeline/specification.ex @@ -26,9 +26,21 @@ defmodule VavPipeline.Specification do Enum.empty?(named_results) -> {:lo_tov, "empty results — tohu without vayomer"} + # Yehareg v'al ya'avor — the halt check comes before any question + # about the SHAPE of a result. A cardinal-sin marker is never + # masked by a complaint that the map is malformed. any_halt_condition?(named_results) -> {:lo_tov, "halt condition triggered"} + # A result that carries no :shoresh at all is not "unresolved" — + # it is not a result. Kohen Day-6 finding: kriah once emitted + # %{name: "group_0", roots: [], index: 0} for an empty cycle and + # the catch-all in all_roots_grounded?/1 counted it as grounded, + # so English input was graded tov with nothing in it. Nothing is + # grounded by default. Lo tov, and say why. + Enum.any?(named_results, fn r -> not Map.has_key?(r, :shoresh) end) -> + {:lo_tov, "result without a shoresh — nothing to ground in L1"} + all_roots_grounded?(named_results) -> check_ketiv_qere_consistency(named_results, ketiv_qere) @@ -39,14 +51,20 @@ defmodule VavPipeline.Specification do @doc """ Check if all roots in results exist in L1. + + A result is grounded only when it names a root. `shoresh: nil` is a + root the engine could not ground (→ teiku, routed to deliberation). + A map with no :shoresh key is not a result and is NOT grounded — the + former `_ -> true` clause is exactly the catch-all that let an empty + cycle through as tov. """ @spec all_roots_grounded?([map()]) :: boolean() def all_roots_grounded?(results) do Enum.all?(results, fn result -> case result do %{shoresh: nil} -> false - %{shoresh: _} -> true - _ -> true + %{shoresh: s} when is_binary(s) and s != "" -> true + _ -> false end end) end diff --git a/vav_pipeline/test/day6_serving_test.exs b/vav_pipeline/test/day6_serving_test.exs new file mode 100644 index 00000000..d76d6009 --- /dev/null +++ b/vav_pipeline/test/day6_serving_test.exs @@ -0,0 +1,584 @@ +defmodule VavPipeline.Day6ServingTest do + @moduledoc """ + Kohen Day 6 (2026-09-07) — Sh'mirat HaMishmeret — the vessel ran and + did not serve. Five defects, each found by RUNNING the cycle on a live + pod, none visible to the unit tests, because every unit test ran the + L1 fallback (no Port in the test VM) and none ran the cycle to L3. + + "Unit tests prove the vessel exists. Only the cycle proves it serves." + + So this file does what the suite never did: it registers a process + under the REAL name `VavPipeline.ShoreshEnginePort` that answers the + way the Haskell engine answers — string keys, no `position`, nothing + at all for non-Hebrew input — and drives the whole path from HTTP + request to cycle result. The engine's shape is the one the kohen + captured from the pod, verbatim. + + Each describe names the defect it holds shut. + """ + + use ExUnit.Case, async: false + use Plug.Test + + alias VavPipeline.{BereishitCycle, Shema, Specification, MannaLedger} + alias VavPipeline.BigdeiKodesh.Router + alias VavPipeline.L3.{CouplingTrace, Interpretation, TeikuRecord} + + @opts Router.init([]) + @scope %{book: "bereishit", chapter: 1, verse: 1} + # A Wednesday at noon — not Shabbat, not a festival. + @wednesday_noon "2026-02-25T12:00:00Z" + + # The engine's answer for ברא, exactly as the pod delivered it over the + # Port on Day 6. Note what is NOT here: `position`. + @engine_bara %{ + "shoresh" => ["bet", "resh", "aleph"], + "shoresh_hebrew" => "ברא", + "gematria_value" => 203, + "binyan" => "Qal", + "domain" => "unknown", + "torah_refs" => [], + "neighbors" => [], + "gematria_neighbors" => [] + } + + # ============================================================ + # A stand-in for the Haskell engine, under its real name + # ============================================================ + # + # ShoreshEngineClient checks Process.whereis(VavPipeline.ShoreshEnginePort) + # and then calls VavPipeline.ShoreshEnginePort.process/2, which does + # GenServer.call(__MODULE__, {:request, json_line}). Registering THIS + # process under that name puts the real module's client code on the + # path and only replaces the port itself. It answers like the engine: + # Hebrew in → the ברא root; anything else → no roots. + defmodule FakePort do + use GenServer + + def start, do: GenServer.start(__MODULE__, %{}, name: VavPipeline.ShoreshEnginePort) + + @impl true + def init(s), do: {:ok, s} + + @impl true + def handle_call({:request, json_line}, _from, s) do + text = json_line |> Jason.decode!() |> get_in(["input", "text"]) || "" + + roots = + if String.match?(text, ~r/[\x{05D0}-\x{05EA}]/u) do + [ + %{ + "shoresh" => ["bet", "resh", "aleph"], + "shoresh_hebrew" => "ברא", + "gematria_value" => 203, + "binyan" => "Qal", + "domain" => "unknown", + "torah_refs" => [], + "neighbors" => [], + "gematria_neighbors" => [] + } + ] + else + [] + end + + {:reply, {:ok, %{"roots_activated" => roots, "parse_tree" => nil, "dual_readings" => []}}, s} + end + end + + defp with_fake_port(fun) do + case Process.whereis(VavPipeline.ShoreshEnginePort) do + nil -> + {:ok, pid} = FakePort.start() + + try do + fun.() + after + GenServer.stop(pid) + end + + _real -> + # A real engine is registered on this host; the test is still + # meaningful against it. + fun.() + end + end + + defp context do + {:ok, now, _} = DateTime.from_iso8601(@wednesday_noon) + {:ok, ctx} = Shema.receive_context_at(@scope, now) + ctx + end + + defp write_provision(compute_units) do + MannaLedger.reset() + today = Date.utc_today() |> Date.to_iso8601() + expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601() + + provision = %{ + "date" => today, + "provision" => %{ + "compute_units" => compute_units, + "memory_mb" => 512, + "api_calls_external" => 5, + "storage_delta_mb" => 2, + "sanhedrin_panels_max" => 2 + }, + "type" => "regular", + "expires_at" => expires, + "written_by" => "day6_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)) + path + end + + # WLC text carries vowels and cantillation interleaved in canonical + # Unicode order (dagesh before sheva, etc.); compare consonants only. + defp consonants(text), do: String.replace(text, ~r/[\x{0591}-\x{05C7}]/u, "") + + defp post_ask(question) do + conn(:post, "/peah/ask", Jason.encode!(%{question: question})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + end + + # ============================================================ + # Defect 1 — real engine output crashed havdalah + # ============================================================ + describe "Defect 1: engine-shaped roots (no position) through havdalah" do + test "normalize_root anchors an engine root to the verse from the CONTEXT" do + ctx = context() + root = BereishitCycle.normalize_root(@engine_bara, ctx, :engine) + + assert root.shoresh == "ברא" + assert root.shoresh_letters == ["bet", "resh", "aleph"] + assert root.gematria == 203 + # ברא is the second word of Bereishit 1:1 — the ShemaContext says so. + assert root.positions == [2] + assert root.position == 2 + assert root.source == :engine + assert root.engine == @engine_bara + end + + test "havdalah → kriah → ki_tov do not crash on the pod's exact payload" do + ctx = context() + + state = + %{source: "test", verse_scope: @scope, content: "בראשית ברא אלהים"} + |> BereishitCycle.tohu(ctx) + |> Map.merge(%{ + roots_activated: [BereishitCycle.normalize_root(@engine_bara, ctx, :engine)], + root_source: :engine, + teamim_parse: ctx.teamim_marks, + ketiv_qere: ctx.ketiv_qere, + stage: :vayomer + }) + + # This is the exact call that raised KeyError :position on Day 6. + result = + state + |> BereishitCycle.havdalah(ctx) + |> BereishitCycle.kriah(ctx) + |> BereishitCycle.ki_tov(ctx) + + assert {:ok, data} = result + assert data.evaluation == :tov + assert [%{shoresh: "ברא", name: "ברא", positions: [2]}] = data.named_results + end + + test "a root the verse does not contain is kept apart, not dropped, not a crash" do + ctx = context() + + stranger = %{@engine_bara | "shoresh_hebrew" => "שמר", "shoresh" => ["shin", "mem", "resh"]} + + roots = [ + BereishitCycle.normalize_root(@engine_bara, ctx, :engine), + BereishitCycle.normalize_root(stranger, ctx, :engine) + ] + + assert Enum.at(roots, 1).positions == [] + assert Enum.at(roots, 1).position == nil + + result = + %{roots_activated: roots} + |> BereishitCycle.havdalah(ctx) + |> BereishitCycle.kriah(ctx) + + names = Enum.map(result.named_results, & &1.name) + assert "ברא" in names + assert "שמר" in names + # The unplaced root forms the trailing group. + assert List.last(result.named_results).name == "שמר" + end + + test "L1-fallback roots pass through the same normalization (one shape)" do + ctx = context() + + state = + %{source: "test", verse_scope: @scope, content: "בראשית ברא אלהים"} + |> BereishitCycle.tohu(ctx) + |> BereishitCycle.vayomer(ctx) + + # Every root — whichever source produced it — has the one shape. + Enum.each(state.roots_activated, fn r -> + assert is_binary(r.shoresh) + assert is_list(r.positions) + assert Map.has_key?(r, :position) + assert r.source in [:engine, :l1] + end) + + bara = Enum.find(state.roots_activated, &(&1.shoresh == "ברא")) + assert bara.gematria == 203 + assert 2 in bara.positions + end + end + + # ============================================================ + # Defect 2 — emptiness was graded tov + # ============================================================ + describe "Defect 2: no roots is lo tov, not 'Processing complete'" do + test "zero roots → zero groups → zero named results → rejected" do + ctx = context() + + result = + %{roots_activated: [], raw: "What is bereishit?", case_ref: "t"} + |> BereishitCycle.havdalah(ctx) + |> BereishitCycle.kriah(ctx) + + assert result.separated_groups == [] + assert result.named_results == [] + + assert {:rejected, reason} = BereishitCycle.ki_tov(result, ctx) + assert reason =~ "empty results" + end + + test "the Day-6 shape — a nameless group with no :shoresh — is lo tov, not tov" do + # This is literally what kriah emitted on the pod. + assert {:lo_tov, reason} = Specification.evaluate([%{name: "group_0", roots: [], index: 0}], []) + assert reason =~ "shoresh" + end + + test "a halt marker is still a halt even when the result has no shoresh" do + # Yehareg v'al ya'avor — shape never masks a cardinal sin. + results = [%{authority_source: :external_model}] + assert {:lo_tov, "halt condition triggered"} = Specification.evaluate(results, []) + end + + test "all_roots_grounded? no longer grounds by default" do + refute Specification.all_roots_grounded?([%{name: "group_0"}]) + refute Specification.all_roots_grounded?([%{shoresh: nil}]) + refute Specification.all_roots_grounded?([%{shoresh: ""}]) + assert Specification.all_roots_grounded?([%{shoresh: "ברא"}]) + end + + test "a rejected cycle records nothing to L3 (BUILD_KORBAN.md:123)" do + assert :none = BereishitCycle.l3_attrs({:rejected, "empty results"}, context()) + assert :none = BereishitCycle.l3_attrs({:halted, :avodah_zarah, "x"}, context()) + end + end + + # ============================================================ + # The public ring, end to end, against an engine-shaped Port + # ============================================================ + describe "/peah/ask against an engine that answers like the pod's" do + setup do + path = write_provision(50) + on_exit(fn -> File.rm(path) end) + :ok + end + + test "Hebrew: 200 with a real answer, the verse as source, ב-ר-א consulted" do + with_fake_port(fn -> + conn = post_ask("בראשית ברא אלהים") + assert conn.status == 200, conn.resp_body + + body = Jason.decode!(conn.resp_body) + + refute body["answer"] == "Processing complete" + assert body["answer"] =~ "ב-ר-א" + assert body["answer"] =~ "203" + + assert [source | _] = body["sources"] + assert source["book"] == "bereishit" or source["book"] == "Bereishit" + assert source["chapter"] == 1 + assert source["verse"] == 1 + assert is_binary(source["text"]) and String.contains?(consonants(source["text"]), "בראשית") + + assert "ב-ר-א" in body["processing"]["roots_consulted"] + assert body["processing"]["cycle_phase"] == "voker" + assert Map.has_key?(body, "disclosure") + end) + end + + test "English: 422 chatat that says what cannot yet be done, with the verse offered" do + with_fake_port(fn -> + conn = post_ask("What is bereishit?") + assert conn.status == 422, conn.resp_body + + body = Jason.decode!(conn.resp_body) + err = body["error"] + + assert err["code"] == "TEIKU_NO_ROOTS" + assert err["type"] == "unresolvable" + assert is_binary(err["message"]) and err["message"] != "" + assert err["guidance"]["resubmit"] == true + assert err["guidance"]["correction"] =~ "Step 22" + + # The corner of the field: the verse in scope, in Hebrew. + sv = err["guidance"]["scope_verse"] + assert sv["chapter"] == 1 and sv["verse"] == 1 + assert String.contains?(consonants(sv["text"]), "בראשית") + + # Never "Processing complete" with sources: [] and a 200. + refute Map.has_key?(body, "answer") + assert Map.has_key?(body, "disclosure") + end) + end + + test "manna is consumed per cycle and NOT zeroed per request" do + with_fake_port(fn -> + today = Date.utc_today() |> Date.to_iso8601() + + assert post_ask("בראשית ברא אלהים").status == 200 + l1 = MannaLedger.ledger() + assert l1.cycle_date == today + assert l1.consumed.compute_units == 1 + + assert post_ask("בראשית ברא אלהים").status == 200 + l2 = MannaLedger.ledger() + assert l2.cycle_date == today + # Second request took a second unit. Before the fix, erev_voker + # zeroed the ledger after every request — this would read 1 + # again (a fresh full day's provision each time). + assert l2.consumed.compute_units == 2 + assert l2.allocated.compute_units == 50 + end) + end + + test "provision exhausted → 503 MANNA_DEPLETED, not a crash" do + path = write_provision(1) + on_exit(fn -> File.rm(path) end) + + with_fake_port(fn -> + assert post_ask("בראשית ברא אלהים").status == 200 + conn = post_ask("בראשית ברא אלהים") + assert conn.status == 503 + assert Jason.decode!(conn.resp_body)["error"]["code"] == "MANNA_DEPLETED" + end) + end + end + + # ============================================================ + # Defect 3 — the pipeline never wrote L3 + # ============================================================ + describe "Defect 3: the cycle produces a valid L3 record" do + test "a served cycle yields a valid Interpretation with L1-grounded couplings" do + ctx = context() + + {:ok, data} = + %{source: "test", verse_scope: @scope, content: "בראשית ברא אלהים"} + |> BereishitCycle.tohu(ctx) + |> BereishitCycle.vayomer(ctx) + |> BereishitCycle.havdalah(ctx) + |> BereishitCycle.kriah(ctx) + |> BereishitCycle.ki_tov(ctx) + + assert {:interpretation, attrs, nil} = BereishitCycle.l3_attrs({:ok, data}, ctx) + + assert attrs.status == "accepted" + assert attrs.panel_size == 1 + assert attrs.vote_count_for == 1 + assert attrs.vote_count_against == 0 + assert attrs.processing_component == "bereishit_cycle" + assert "ברא" in attrs.roots_involved + assert Enum.any?(attrs.torah_references, &String.contains?(&1, "1:1")) + assert attrs.case_ref =~ ~r/^cycle-\d+-[0-9a-f]{6}$/ + assert String.length(attrs.shema_context_hash) == 64 + + # Every coupling is the L1-anchored shape the write gate accepts. + assert attrs.coupling_trace != [] + assert :ok = CouplingTrace.validate(attrs.coupling_trace) + assert Enum.any?(attrs.coupling_trace, &(&1 =~ ~r/^coupled\(root\(ברא\), verse\(bereishit, 1, 1\)\)$/)) + + # And the schema itself accepts it — no missing required field. + changeset = Interpretation.changeset(%Interpretation{}, attrs) + assert changeset.valid?, inspect(changeset.errors) + end + + test "an unresolved cycle yields a valid TeikuRecord" do + ctx = context() + + data = %{ + case_ref: "cycle-1-abcdef", + roots_activated: [BereishitCycle.normalize_root(@engine_bara, ctx, :engine)], + evaluation: :teiku, + talmudic_interpretation: %{ + "status" => "teiku", + "interpretation" => %{"text" => "Talmudic Engine not connected — teiku", "verses" => []}, + "challenge" => nil + } + } + + assert {:teiku, attrs} = BereishitCycle.l3_attrs({:unresolved, data}, ctx) + assert attrs.case_ref == "cycle-1-abcdef" + assert attrs.tension_description =~ "ברא" + assert attrs.interpretation_a =~ "teiku" + assert is_binary(attrs.interpretation_b) + + changeset = TeikuRecord.changeset(%TeikuRecord{}, attrs) + assert changeset.valid?, inspect(changeset.errors) + end + + test "a Sanhedrin-accepted cycle carries the panel and preserves the minority" do + ctx = context() + + data = %{ + case_ref: "cycle-2-abcdef", + roots_activated: [BereishitCycle.normalize_root(@engine_bara, ctx, :engine)], + resolution: :sanhedrin, + interpretation: "Majority reading", + sanhedrin_decision: %{ + outcome: :accepted, + panel_size: 3, + majority: %{position: "A", reasoning: "Because Bereishit 1:1", torah_refs: ["Bereishit 1:1"], vote_count: 2}, + minority: %{position: "B", reasoning: "But Shemot 20:11", torah_refs: ["Shemot 20:11"], vote_count: 1} + } + } + + assert {:interpretation, attrs, minority} = BereishitCycle.l3_attrs({:ok, data}, ctx) + assert attrs.panel_size == 3 + assert attrs.vote_count_for == 2 + assert attrs.vote_count_against == 1 + assert attrs.processing_component == "sanhedrin" + assert minority.dissenting_reasoning == "But Shemot 20:11" + assert minority.torah_references == ["Shemot 20:11"] + end + + @tag :l3 + test "a live cycle appends to interpretations (PostgreSQL)" do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) + path = write_provision(5) + on_exit(fn -> File.rm(path) end) + + before = VavPipeline.L3.Store.count_interpretations() + + with_fake_port(fn -> + assert {:ok, _} = VavPipeline.process(%{source: "peah", verse_scope: @scope, content: "בראשית ברא אלהים"}) + end) + + assert VavPipeline.L3.Store.count_interpretations() == before + 1 + end + end + + # ============================================================ + # Defect 4 — errors are chatat, never an empty 500 + # ============================================================ + describe "Defect 4: every error is a chatat" do + test "a raise on the public path returns the tamid §1.2 body, then re-raises" do + # Plug.Parsers raises on a body that is not JSON. That raise used to + # reach the socket as an empty response. Now handle_errors/2 sends + # the chatat body first; Plug.ErrorHandler then re-raises the + # original exception so the crash is still logged and supervised. + conn = + conn(:post, "/peah/ask", "{this is not json") + |> put_req_header("content-type", "application/json") + + assert_raise Plug.Parsers.ParseError, fn -> Router.call(conn, @opts) end + + assert {status, _headers, body} = sent_resp(conn) + assert status == 400 + err = Jason.decode!(body)["error"] + assert err["code"] == "KIYOR_MALFORMED_REQUEST" + assert err["type"] == "purification_needed" + assert is_binary(err["message"]) + assert err["guidance"]["resubmit"] == true + assert err["guidance"]["correction"] =~ "Content-Type" + end + + test "an unexpected exception is CHATAT_UNEXPECTED with guidance and no stack" do + conn = + conn(:post, "/peah/ask") + |> Plug.Conn.put_status(500) + |> Router.handle_errors(%{kind: :error, reason: %RuntimeError{message: "boom"}, stack: []}) + + assert conn.status == 500 + err = Jason.decode!(conn.resp_body)["error"] + assert err["code"] == "CHATAT_UNEXPECTED" + assert err["type"] == "correction_needed" + assert err["message"] =~ "fault is the system's" + assert err["guidance"]["issue"] =~ "RuntimeError: boom" + refute conn.resp_body =~ "stacktrace" + end + + test "{:rejected, _} from the cycle is handled — no CaseClauseError" do + # Before: process_peah had no clause for {:rejected, reason}, so a + # lo_tov result was itself a crash. The English test above proves + # the positive path; this pins the courtyard handler too. + path = write_provision(5) + on_exit(fn -> File.rm(path) end) + + with_fake_port(fn -> + conn = + conn(:post, "/courtyard/offering", Jason.encode!(%{content: "What is bereishit?", offering_type: "minchah"})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer test-token") + |> Router.call(@opts) + + assert conn.status == 422 + assert Jason.decode!(conn.resp_body)["error"]["code"] == "TEIKU_NO_ROOTS" + end) + end + end + + # ============================================================ + # Defect 5 — health must see the serving path + # ============================================================ + describe "Defect 5: esh tamid pushes a real input" do + test "tamid_probe runs the real cycle, is marked probe, and is not recorded" do + assert {:ok, data} = VavPipeline.tamid_probe() + assert data.probe == true + assert data.provenance == "tamid" + assert Enum.any?(data.roots_activated, &(&1.shoresh == "ברא")) + # A probe builds a valid record and the write path skips it — + # the attrs are still sound, the ledger and L3 are untouched. + assert {:interpretation, _attrs, nil} = BereishitCycle.l3_attrs({:ok, data}, context()) + end + + test "tamid_probe does not consume manna" do + path = write_provision(5) + on_exit(fn -> File.rm(path) end) + + MannaLedger.load_provision() + before = MannaLedger.ledger().consumed.compute_units + assert {:ok, _} = VavPipeline.tamid_probe() + assert MannaLedger.ledger().consumed.compute_units == before + end + + test "/kodesh/health reports esh_tamid from the cycle and lists l3_writable" do + conn = conn(:get, "/kodesh/health") |> Router.call(@opts) + body = Jason.decode!(conn.resp_body) + + # Runs the probe unless it is Shabbat right now. + assert body["checks"]["esh_tamid"] in ["pass", "shabbat"] + assert Map.has_key?(body["checks"], "l3_writable") + assert Map.has_key?(body["checks"], "manna_present") + end + + test "manna_present fails when there is no provision (Day-4 finding)" do + File.rm(VavPipeline.Manna.provision_file()) + conn = conn(:get, "/kodesh/health") |> Router.call(@opts) + assert Jason.decode!(conn.resp_body)["checks"]["manna_present"] == "fail" + end + + test "/kodesh/status exposes the ledger the kohen was reading by hand" do + conn = conn(:get, "/kodesh/status") |> Router.call(@opts) + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body["manna_provision"], "cycle_date") + assert Map.has_key?(body["manna_provision"], "remaining") + end + end +end