serve: the minchah is answered, emptiness is not tov, the oral tradition is written (kohen Day 6)
TORAH ANCHORS:
Vayikra 2:1 — וְנֶפֶשׁ כִּי תַקְרִיב קָרְבַּן מִנְחָה. "When a SOUL brings a
grain offering." Rashi: only of the minchah does Torah say nefesh,
because the poor person who gives from their little has given their
soul. PRD §3.5 makes the pe'ah question the minchah. On Day 6 the
first minchah brought to this system — "What is bereishit?" — came
back as HTTP 200 and the words "Processing complete", with sources: []
and roots_consulted: []. The soul's offering was accepted and nothing
was given back.
Vayikra 19:9-10 — לֶעָנִי וְלַגֵּר תַּעֲזֹב אֹתָם. The corner is LEFT for the
stranger. A corner that has been harvested and is still called pe'ah
is the lie this commit removes: when the system cannot answer, it now
says so, and it leaves the verse itself — text, in Hebrew — in the
error body. The ger walks away with something real or with an honest
"not yet"; never with a 200 that says nothing.
Devarim 17:9 — וְדָרַשְׁתָּ וְהִגִּידוּ לְךָ אֵת דְּבַר הַמִּשְׁפָּט. Already the
anchor of l3/store.ex: "the inquiry requires that what is found is
RECORDED." Store existed. Nothing called it.
=====================================================================
WHAT THE KOHEN FOUND (BUILDER_HANDOFF_day6.md, 2026-09-07)
=====================================================================
Five defects, found by running the cycle on a live pod, invisible to
437 green unit tests because every test ran the L1 fallback (no Port
in the test VM) and none ran the cycle to L3:
1. Real Hebrew input crashed havdalah — KeyError :position.
The engine's roots_activated[] (vayakhel §3.1) has no position;
only vayomer_fallback ever set one.
2. English input was graded :tov with nothing in it.
separate_by_boundaries([]) → [[]] → kriah named "group_0" with
no :shoresh → all_roots_grounded?'s `_ -> true` counted it
grounded → "Processing complete".
3. The pipeline never wrote L3. record_interpretation/1,
record_minority/1, record_teiku/1: zero callers. Day 7's
SELECT count(*) FROM interpretations could not exceed 0.
4. Errors were not chatat. No Plug.ErrorHandler; a raise returned
an EMPTY 500. Also: process_peah had no clause for
{:rejected, _} — a lo_tov result was itself a CaseClauseError.
5. Health was blind to serving. esh_tamid = ShabbatTimer.resting?;
it read "pass" while /peah/ask was 500-ing. manna_present passed
on {:ok, :no_provision}.
Plus, confirmed in motion: MannaLedger never consumed from; a
completed cycle left cycle_date: nil.
=====================================================================
WHAT CHANGED
=====================================================================
bereishit_cycle.ex
- normalize_root/3: ONE root shape for both sources. Engine roots
(string keys, no position) and L1 roots (atom keys) come out as
%{shoresh, shoresh_letters, gematria, binyan, domain, positions,
position, source, engine}. `positions` is looked up in
context.verse.words — the ShemaContext gives the WHERE; the
engine gives the WHAT. That is what havdalah's own @doc always
said, and vayakhel §3.1 is unchanged: the engine was conforming.
- separate_by_boundaries([], _) → [] (was [[]]). Roots the verse
does not place form their own trailing group instead of crashing
or vanishing.
- kriah never names an empty group and always carries :shoresh.
- erev_voker composes the answer (roots, gematria, te'amim groups,
ki tov — the system's own reasoning, not prose from a model) and
sources (the context verse: book/chapter/verse/ref/text, tamid
§1.1's shape), then RECORDS: Interpretation for {:ok}, TeikuRecord
for {:unresolved}, NOTHING for {:rejected} (BUILD_KORBAN.md:123:
rejected, not processed into empty propositions). l3_attrs/2 is
pure and tested without PostgreSQL. coupling_trace is the
L1-anchored shape the write gate accepts, and only for roots that
occur in the verse. A record that fails — validation OR a raise
from a database that is down — is logged, sounds chatzotzrot
:assembly_leaders, and does not turn a served answer into a 500.
- case_ref minted once in tohu; Talmudic routing and the L3 record
use the same one.
- probe: true (tamid) runs the whole cycle and skips the write.
specification.ex
- all_roots_grounded?/1: `_ -> true` is gone. No :shoresh → not
grounded. shoresh: nil stays :teiku (the existing test).
- evaluate/2: halt check FIRST (yehareg v'al ya'avor — shape never
masks a cardinal sin), then a result with no :shoresh is lo_tov
with a reason that names it.
manna.ex / vav_pipeline.ex
- Manna.gather_for_cycle/0: load (idempotent per date) + consume
one compute unit, called from VavPipeline.process/1. The ledger
is now consumed from.
- Manna.release_at_day_boundary/0: surplus is released when the
DATE rolls, not per request. enforce_cycle_boundary zeroes the
ledger and clears its date; called after every request it would
have re-gathered a full day's provision on the next one —
infinite manna. Shemot 16:19: until MORNING.
- The late-manna trap: load_provision pins cycle_date=today with a
ZERO allocation when the file is absent, then :already_loaded for
the rest of the day — a provision written after the first request
would never be gathered. gather_for_cycle re-gathers when the
ledger holds nothing for today.
- VavPipeline.tamid_probe/0: builds a real context, honours the
cloud, runs the real cycle on Bereishit 1:1 in Hebrew, consumes
no manna, writes no L3.
bigdei_kodesh/router.ex
- use Plug.ErrorHandler; handle_errors/2 sends tamid §1.2's
code/type/message/guidance, logs, signals :assembly_leaders, then
re-raises so the crash is still supervised. 400 → KIYOR_MALFORMED_
REQUEST; else CHATAT_UNEXPECTED. No stack in the body.
- {:rejected, reason} handled on pe'ah AND courtyard: 422
TEIKU_NO_ROOTS for the empty-results case, with guidance naming
Architecture Step 22 and guidance.scope_verse carrying the verse
in Hebrew; 422 LO_TOV otherwise.
- {:waiting, :provision_exhausted} → MANNA_DEPLETED (was falling
through to CLOUD_MOVING).
- esh_tamid runs VavPipeline.tamid_probe/0 — "shabbat" on Shabbat,
"pass" only when roots came back. manna_present fails on
:no_provision. l3_writable added (Repo up AND SELECT 1 answers;
"absent" without a Repo, counted as a failure). /kodesh/status
exposes manna_provision (allocated / consumed / remaining).
- roots_consulted as letter-hyphenated Hebrew ("ב-ר-א").
test/day6_serving_test.exs — 25 tests
A process registered under the REAL name VavPipeline.ShoreshEnginePort
answers the way the pod's engine answered (string keys, no position;
nothing for non-Hebrew), so the engine-shaped path runs end to end
through the router with no binary. Each describe names the defect it
holds shut. One @tag :l3 test asserts count(interpretations) rises
by one after a live cycle.
KOHEN.md — Day 6 gains the Hebrew request, the expected 422 for
English, and the health shape; Day 7 sends FIVE Hebrew + FIVE English
and expects the L3 count to rise by the Hebrew ones, with the reason.
=====================================================================
VERIFIED — the kohen's exact procedure, locally, against the REAL
Haskell binary over the Port, with L3 deliberately unreachable
=====================================================================
POST /peah/ask {"question":"בראשית ברא אלהים"}
200. answer: "Roots activated in Bereishit 1:1: ב-ר-א (gematria
203, Qal, word 2). Te'amim divide them into 1 group(s): ב-ר-א.
Ki tov: ..." sources[0]: Bereishit 1:1 with Hebrew text.
roots_consulted: ["ב-ר-א"]. Cycle: 31ms.
POST /peah/ask {"question":"What is bereishit?"}
422 TEIKU_NO_ROOTS / unresolvable, guidance.scope_verse =
Bereishit 1:1 in Hebrew, no answer key. Cycle: 3ms, :rejected.
POST /peah/ask '{this is not json'
400 KIYOR_MALFORMED_REQUEST with guidance — 406 bytes, not 0.
GET /kodesh/health
esh_tamid "pass" (from a real cycle, 7ms, no L3 attempt),
l3_writable "fail" (DB unreachable by design), overall degraded.
GET /kodesh/status
manna_provision: consumed 2 after two cycles; the probe took none.
Log: L3_WRITE_FAILED + chatzotzrot for each served Hebrew cycle;
"activated no roots" for English; CHATAT_UNEXPECTED for the bad
body. The 2–5s per request was ENTIRELY DBConnection queuing
against the dead database — the cycles themselves were 1–31ms.
mix test — 437 tests, 0 failures, 36 excluded (:l3)
Doorpost.verify_identity() :ok — no @spec_files entry touched
l1/integrity.sha256 unchanged: 99bbd07a…4ea377
NOT FIXED, RAISED:
- The engine's parse_tree is null on the pod because Server.hs reads
"l1/torah/..." relative to cwd. Nothing consumes parse_tree yet
(havdalah uses context.teamim_marks), so nothing is lost today.
- cloud.ex Shabbat is the UTC stub; zmanim absent (kohen Day 5).
- The L3 write is on the request's critical path by choice: the
offering is not complete until the altar portion is recorded. With
L3 down that costs the queue timeout per request AND is visible in
l3_writable. If that trade should go the other way, it is one
Task.start, and a spec question first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e34001c58
commit
f1ab7c89f7
7 files changed
+1698
-80
No files matched your search
@@ -350,18 +350,59 @@ curl http://<host>/kodesh/status | jq '.halt_status'
|
||||
curl http://<host>/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://<host>/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://<host>/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://<host>/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://<host>/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://<host>/peah/ask \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"question\":\"Test query $i\"}"
|
||||
@@ -378,9 +419,18 @@ curl -X POST http://<host>/chatzer/sanhedrin \
|
||||
# Status after load
|
||||
curl http://<host>/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://<host>/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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user