Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53778f372e | ||
|
|
861cd4e64e | ||
|
|
4e0eb2f983 | ||
|
|
ab6ca1ebcf | ||
|
|
e01181959d | ||
|
|
4bffa01e0e | ||
|
|
046010f0ed | ||
|
|
7c40200dfc |
No files matched your search
@@ -53,8 +53,8 @@ You inherit infrastructure that has been provisioned and verified:
|
|||||||
| Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. |
|
| Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. |
|
||||||
| Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. |
|
| Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. |
|
||||||
| cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. |
|
| cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. |
|
||||||
| Gitea (persistent storage) | Running. OAuth app + PAT exist for the operator's account. |
|
| Gitea (persistent storage) | Running. Public HTTPS at `https://git.bikoret.ch` (ROOT_URL) since 2026-10-19; git also over `http://185.19.28.170:3000` and SSH :22. The web UI needs the https door (Secure cookie). |
|
||||||
| Gitea MCP server | Running at `https://mcp.shorashim.ch`. Connected to claude.ai. |
|
| Gitea MCP server | `https://mcp.bikoret.ch/mcp`, MCP OAuth mode: each user authorizes with their own Gitea account (Gitea OAuth2 app "Claude", PKCE, client ID in the claude.ai connector's Advanced settings). Apply: `bash ~/.local/bin/gitea-public-apply`; check: `gitea-public-verify`. The old `mcp.shorashim.ch` and the shared static token are retired. |
|
||||||
| L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). |
|
| L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). |
|
||||||
| Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. |
|
| Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. |
|
||||||
| `shorashim-secrets` (DATABASE_URL) | **Verify before assuming.** Same as above. If present, also verify the role it points at (the architecture requires `shorashim_app`, not `avnadmin`). |
|
| `shorashim-secrets` (DATABASE_URL) | **Verify before assuming.** Same as above. If present, also verify the role it points at (the architecture requires `shorashim_app`, not `avnadmin`). |
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ Kept in `deploy/k8s/`:
|
|||||||
- `manna-volume.yaml` — PVC for provision file
|
- `manna-volume.yaml` — PVC for provision file
|
||||||
- `deployment.yaml` — the pod with 3 containers + 2 init containers (`shoresh-engine-install`, `migrate`). Four images are still built and pushed; shoresh-engine ships a binary, not a running container.
|
- `deployment.yaml` — the pod with 3 containers + 2 init containers (`shoresh-engine-install`, `migrate`). Four images are still built and pushed; shoresh-engine ships a binary, not a running container.
|
||||||
- `service.yaml` — ClusterIP and external LoadBalancer
|
- `service.yaml` — ClusterIP and external LoadBalancer
|
||||||
- `gitea-mcp-deployment.yaml` — separate MCP server for Gitea connector (optional)
|
- `gitea-mcp-deployment.yaml` — Gitea MCP server for the claude.ai / Claude Code connector, MCP OAuth mode (users act as themselves in Gitea)
|
||||||
- `gitea-mcp-ingress.yaml` — TLS ingress for the MCP server
|
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
|
||||||
- `gitea-mcp-secret.yaml` — template for Gitea PAT
|
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
|
||||||
|
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
|
||||||
|
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch`
|
||||||
|
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
|
||||||
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Ingress for Gitea itself — public HTTPS at git.bikoret.ch
|
||||||
|
#
|
||||||
|
# Added 2026-10-19 so the Gitea MCP server can run in MCP OAuth mode:
|
||||||
|
# claude.ai and Claude Code send the user to Gitea's own OAuth2
|
||||||
|
# authorize page, which must be reachable over HTTPS at Gitea's ROOT_URL
|
||||||
|
# (gitea-public-patch.yaml sets ROOT_URL = https://git.bikoret.ch/).
|
||||||
|
#
|
||||||
|
# Gitea lives in namespace temple-council (Service gitea, 80 → 3000).
|
||||||
|
# The plain-HTTP TCP passthrough on :3000 and SSH on :22
|
||||||
|
# (ingress-tcp-services.yaml) stay as they are; this adds a third door.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. DNS: git.bikoret.ch → 185.19.28.170 (Infomaniak zone)
|
||||||
|
# 2. ClusterIssuer letsencrypt-prod
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: gitea
|
||||||
|
namespace: temple-council
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: gitea
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
# git push of large packs; Gitea enforces its own limits
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- git.bikoret.ch
|
||||||
|
secretName: gitea-bikoret-tls
|
||||||
|
rules:
|
||||||
|
- host: git.bikoret.ch
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: gitea
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
# Gitea MCP Server — public connector for claude.ai
|
# Gitea MCP Server — public connector for claude.ai and Claude Code
|
||||||
#
|
#
|
||||||
# Deploys the official Gitea MCP server (gitea.com/gitea/gitea-mcp)
|
# Deploys the official Gitea MCP server (gitea.com/gitea/gitea-mcp)
|
||||||
# in HTTP mode, exposing /mcp endpoint for Streamable HTTP transport.
|
# in HTTP mode with MCP OAuth (-oauth): every client authorizes against
|
||||||
|
# Gitea with its OWN account (PKCE, no client secret), and the server
|
||||||
|
# forwards that bearer token to Gitea per request. No shared static
|
||||||
|
# token is used any more (gitea-mcp-secrets is kept only for a rollback
|
||||||
|
# to the pre-2026-10-19 static-token mode).
|
||||||
#
|
#
|
||||||
# The MCP server connects to the Gitea backend over the cluster network
|
# The server serves /.well-known/oauth-protected-resource naming Gitea
|
||||||
# (gitea.temple-council.svc.cluster.local) using a Personal Access Token
|
# as the authorization server, so BOTH origins must be public HTTPS:
|
||||||
# stored in gitea-mcp-secrets. Until 2026-10-06 it went out to Gitea's
|
# -oauth-public-url https://mcp.bikoret.ch (gitea-mcp-ingress.yaml)
|
||||||
# own NLB (185.19.29.94) and back in; that NLB is gone — Gitea is behind
|
# -H https://git.bikoret.ch (gitea-ingress.yaml;
|
||||||
# the shared ingress now (ingress-tcp-services.yaml).
|
# Gitea ROOT_URL must be the same, gitea-public-patch.yaml)
|
||||||
|
# /mcp accepts only the Host from -oauth-public-url, and reverse proxies
|
||||||
|
# must pass Mcp-Protocol-Version, Mcp-Method and Mcp-Name through
|
||||||
|
# (ingress-nginx does).
|
||||||
|
#
|
||||||
|
# One-time in Gitea (Settings → Applications → Manage OAuth2 Applications):
|
||||||
|
# name Claude
|
||||||
|
# redirect URIs https://claude.ai/api/mcp/auth_callback
|
||||||
|
# http://127.0.0.1/callback (Claude Code; any port)
|
||||||
|
# confidential UNCHECKED (PKCE)
|
||||||
|
# The client ID goes into the claude.ai connector's Advanced settings,
|
||||||
|
# with the connector URL https://mcp.bikoret.ch/mcp.
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -33,18 +48,13 @@ spec:
|
|||||||
args:
|
args:
|
||||||
- "-t"
|
- "-t"
|
||||||
- "http"
|
- "http"
|
||||||
|
- "-oauth"
|
||||||
|
- "-oauth-public-url"
|
||||||
|
- "https://mcp.bikoret.ch"
|
||||||
- "--host"
|
- "--host"
|
||||||
- "http://gitea.temple-council.svc.cluster.local"
|
- "https://git.bikoret.ch"
|
||||||
- "--port"
|
- "--port"
|
||||||
- "8080"
|
- "8080"
|
||||||
env:
|
|
||||||
- name: GITEA_ACCESS_TOKEN
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: gitea-mcp-secrets
|
|
||||||
key: GITEA_TOKEN
|
|
||||||
- name: GITEA_HOST
|
|
||||||
value: "http://gitea.temple-council.svc.cluster.local"
|
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: http
|
name: http
|
||||||
@@ -56,12 +66,14 @@ spec:
|
|||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
cpu: "250m"
|
cpu: "250m"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket:
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Ingress for Gitea MCP Server — public HTTPS endpoint
|
# Ingress for Gitea MCP Server — public HTTPS endpoint
|
||||||
# Routes mcp.shorashim.ch to the gitea-mcp service
|
# Routes mcp.bikoret.ch to the gitea-mcp service (moved from
|
||||||
|
# mcp.bikoret.ch on 2026-10-19 with the switch to MCP OAuth; the
|
||||||
|
# server only answers /mcp for the host in -oauth-public-url).
|
||||||
# TLS via cert-manager (Let's Encrypt)
|
# TLS via cert-manager (Let's Encrypt)
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# 1. nginx-ingress controller installed
|
# 1. nginx-ingress controller installed
|
||||||
# 2. cert-manager installed
|
# 2. cert-manager installed
|
||||||
# 3. ClusterIssuer letsencrypt-prod applied
|
# 3. ClusterIssuer letsencrypt-prod applied
|
||||||
# 4. DNS: mcp.shorashim.ch → Ingress external IP
|
# 4. DNS: mcp.bikoret.ch → 185.19.28.170 (the one NLB; Infomaniak zone)
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
@@ -22,10 +24,10 @@ spec:
|
|||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- mcp.shorashim.ch
|
- mcp.bikoret.ch
|
||||||
secretName: gitea-mcp-tls
|
secretName: gitea-mcp-bikoret-tls
|
||||||
rules:
|
rules:
|
||||||
- host: mcp.shorashim.ch
|
- host: mcp.bikoret.ch
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
# Gitea MCP Server — secrets template
|
# Gitea MCP Server — secrets template
|
||||||
# NEVER commit actual values. Fill in and apply manually.
|
# NEVER commit actual values. Fill in and apply manually.
|
||||||
#
|
#
|
||||||
# Two secrets needed:
|
# Since 2026-10-19 the MCP server runs in OAuth mode and uses NO static
|
||||||
|
# token: users authorize with their own Gitea account. This secret is
|
||||||
|
# kept only for a rollback to static-token mode (see the deployment).
|
||||||
|
#
|
||||||
|
# Two secrets needed (static-token mode):
|
||||||
# 1. GITEA_TOKEN: Personal Access Token from Gitea
|
# 1. GITEA_TOKEN: Personal Access Token from Gitea
|
||||||
# Create at: http://185.19.28.170:3000/user/settings/applications
|
# Create at: http://185.19.28.170:3000/user/settings/applications
|
||||||
# Permissions: repo (read/write)
|
# Permissions: repo (read/write)
|
||||||
#
|
#
|
||||||
# 2. OAuth Client ID/Secret: Registered OAuth app in Gitea
|
# 2. OAuth Client ID/Secret: Registered OAuth app in Gitea
|
||||||
# Create at: http://185.19.28.170:3000/user/settings/applications
|
# Create at: http://185.19.28.170:3000/user/settings/applications
|
||||||
# Redirect URI: https://claude.ai/api/mcp/auth_callback
|
# Redirect URIs: https://claude.ai/api/mcp/auth_callback
|
||||||
# (These go into claude.ai connector settings, not this secret)
|
# http://127.0.0.1/callback (Claude Code)
|
||||||
|
# Confidential Client: UNCHECKED (PKCE). Only the Client ID is needed;
|
||||||
|
# it goes into the claude.ai connector's Advanced settings, not here.
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Move Gitea and its MCP server to public HTTPS on bikoret.ch and switch
|
||||||
|
# the MCP server to MCP OAuth (2026-10-19). Run by the transmitter:
|
||||||
|
# ! bash ~/.local/bin/gitea-public-apply
|
||||||
|
# Idempotent. Needs DNS first: git.bikoret.ch and mcp.bikoret.ch → 185.19.28.170.
|
||||||
|
#
|
||||||
|
# Rollback (static-token mode on mcp.shorashim.ch, as before 2026-10-19):
|
||||||
|
# kubectl -n temple-council set env deploy/gitea \
|
||||||
|
# GITEA__server__ROOT_URL=http://gitea.temple-council.ch/ \
|
||||||
|
# GITEA__server__DOMAIN=gitea.temple-council.ch GITEA__server__SSH_DOMAIN-
|
||||||
|
# then restore deploy/k8s/gitea-mcp-*.yaml from commit 861cd4e6 and apply them.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO="${REPO:-$HOME/bikoret}"
|
||||||
|
cd "$REPO"
|
||||||
|
NLB=185.19.28.170
|
||||||
|
for h in git.bikoret.ch mcp.bikoret.ch; do
|
||||||
|
ip=$(dig +short "$h" | tail -1 || true)
|
||||||
|
if [ "$ip" != "$NLB" ]; then
|
||||||
|
echo "DNS: $h → '${ip:-nothing}' (need $NLB). Add the A record at Infomaniak first." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "== 1/4 ingresses (certificates issue once DNS resolves)"
|
||||||
|
kubectl apply -f deploy/k8s/gitea-ingress.yaml
|
||||||
|
kubectl apply -f deploy/k8s/gitea-mcp-ingress.yaml
|
||||||
|
echo "== 2/4 Gitea ROOT_URL → https://git.bikoret.ch/ (one restart)"
|
||||||
|
kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
|
||||||
|
kubectl -n temple-council rollout status deploy/gitea --timeout=180s
|
||||||
|
echo "== 3/4 gitea-mcp in OAuth mode at https://mcp.bikoret.ch"
|
||||||
|
kubectl apply -f deploy/k8s/gitea-mcp-deployment.yaml
|
||||||
|
kubectl -n shorashim rollout status deploy/gitea-mcp --timeout=120s
|
||||||
|
echo "== 4/4 certificates"
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
ready=$(kubectl get certificate -A -o jsonpath='{range .items[?(@.metadata.name=="gitea-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}{range .items[?(@.metadata.name=="gitea-mcp-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}' 2>/dev/null || true)
|
||||||
|
[ "$ready" = "TrueTrue" ] && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
kubectl get certificate -A | grep -E "NAME|bikoret"
|
||||||
|
echo "== verify"
|
||||||
|
bash "$REPO/deploy/k8s/gitea-public-verify.sh"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Strategic-merge patch for the Gitea Deployment (namespace temple-council,
|
||||||
|
# which is NOT otherwise managed from this repo): make Gitea's own URL the
|
||||||
|
# public HTTPS host, so its OAuth2 authorize/token endpoints and the
|
||||||
|
# issuer in /.well-known/openid-configuration are reachable by claude.ai.
|
||||||
|
#
|
||||||
|
# kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
|
||||||
|
#
|
||||||
|
# The Gitea image rewrites app.ini from GITEA__section__KEY at start, so
|
||||||
|
# this replaces ROOT_URL/DOMAIN (were http://gitea.temple-council.ch, a
|
||||||
|
# name that never resolved) and SSH_DOMAIN (was 185.19.29.94, an NLB IP
|
||||||
|
# released on 2026-10-06). Clone URLs shown by Gitea become
|
||||||
|
# https://git.bikoret.ch/... and git@git.bikoret.ch:...; the old
|
||||||
|
# http://185.19.28.170:3000 remote keeps working for push/pull.
|
||||||
|
# Side effect: with an https ROOT_URL Gitea marks its session cookie
|
||||||
|
# Secure, so the WEB UI must be used at https://git.bikoret.ch, not on
|
||||||
|
# the plain-HTTP :3000 door (git over :3000 is unaffected).
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: gitea
|
||||||
|
env:
|
||||||
|
- name: GITEA__server__ROOT_URL
|
||||||
|
value: "https://git.bikoret.ch/"
|
||||||
|
- name: GITEA__server__DOMAIN
|
||||||
|
value: "git.bikoret.ch"
|
||||||
|
- name: GITEA__server__SSH_DOMAIN
|
||||||
|
value: "git.bikoret.ch"
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Read-only checks for the public Gitea + OAuth MCP setup. Exit 0 = all pass.
|
||||||
|
# bash ~/.local/bin/gitea-public-verify
|
||||||
|
set -u
|
||||||
|
fail=0
|
||||||
|
chk() { # label expected actual
|
||||||
|
if [ "$2" = "$3" ]; then echo "ok $1: $3"; else echo "FAIL $1: got '$3', want '$2'"; fail=1; fi
|
||||||
|
}
|
||||||
|
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
|
||||||
|
chk "https://git.bikoret.ch/ status" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://git.bikoret.ch/ || echo err)"
|
||||||
|
chk "Gitea OIDC issuer" "https://git.bikoret.ch" "$(curl -sS -m 15 https://git.bikoret.ch/.well-known/openid-configuration | python3 -c 'import sys,json;print(json.load(sys.stdin)["issuer"].rstrip("/"))' 2>/dev/null || echo err)"
|
||||||
|
chk "mcp /healthz" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://mcp.bikoret.ch/healthz || echo err)"
|
||||||
|
prm=$(curl -sS -m 15 https://mcp.bikoret.ch/.well-known/oauth-protected-resource || echo err)
|
||||||
|
chk "protected-resource names Gitea" yes "$(printf '%s' "$prm" | grep -q 'git.bikoret.ch' && echo yes || echo no)"
|
||||||
|
chk "POST /mcp without token → 401" 401 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST https://mcp.bikoret.ch/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT" || echo err)"
|
||||||
|
chk "git over :3000 still answers" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' http://185.19.28.170:3000/api/v1/version || echo err)"
|
||||||
|
echo "protected-resource: $prm"
|
||||||
|
exit $fail
|
||||||
@@ -145,13 +145,29 @@ handle_info(_Info, State) ->
|
|||||||
%% Internal — gate-keeper checks
|
%% Internal — gate-keeper checks
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
do_check_case(Case) ->
|
%% The context is a map (authority_source, action, ...). Anything else is
|
||||||
Context = Case#sanhedrin_case.context,
|
%% a malformed case, refused here — never reasoned about, never a crash.
|
||||||
|
%% Tablet 55 (2026-10-19): the Tamid's own example request carried a
|
||||||
|
%% LIST as context; maps:get raised, and this gen_server and escalation
|
||||||
|
%% died with the caller's input. A gate that can be felled by what it is
|
||||||
|
%% handed is not a gate. Fail closed, stay standing.
|
||||||
|
do_check_case(#sanhedrin_case{context = Context}) when is_map(Context) ->
|
||||||
with_chain([
|
with_chain([
|
||||||
fun() -> check_avodah_zarah(Context) end,
|
fun() -> check_avodah_zarah(Context) end,
|
||||||
fun() -> check_shfichut_damim(Context) end,
|
fun() -> check_shfichut_damim(Context) end,
|
||||||
fun() -> check_gilui_arayot(Context) end
|
fun() -> check_gilui_arayot(Context) end
|
||||||
]).
|
]);
|
||||||
|
do_check_case(#sanhedrin_case{context = Other}) ->
|
||||||
|
{halt, malformed_case,
|
||||||
|
iolist_to_binary([<<"case context must be a map, got ">>,
|
||||||
|
atom_to_binary(type_name(Other), utf8)])}.
|
||||||
|
|
||||||
|
type_name(T) when is_list(T) -> list;
|
||||||
|
type_name(T) when is_binary(T) -> binary;
|
||||||
|
type_name(T) when is_atom(T) -> atom;
|
||||||
|
type_name(T) when is_tuple(T) -> tuple;
|
||||||
|
type_name(T) when is_number(T) -> number;
|
||||||
|
type_name(_) -> other.
|
||||||
|
|
||||||
do_check_vote(_Vote) ->
|
do_check_vote(_Vote) ->
|
||||||
%% Vote-level halt detection is the responsibility of the upstream
|
%% Vote-level halt detection is the responsibility of the upstream
|
||||||
|
|||||||
+124
-93
@@ -11,6 +11,20 @@
|
|||||||
%%%
|
%%%
|
||||||
%%% Judges deliberate independently — they do not see
|
%%% Judges deliberate independently — they do not see
|
||||||
%%% other judges' votes until the panel collects them.
|
%%% other judges' votes until the panel collects them.
|
||||||
|
%%%
|
||||||
|
%%% Tablet 55 (2026-10-19): until this rewrite the judge chose one of
|
||||||
|
%%% six canned positions by a hash over the question, its own pid and
|
||||||
|
%%% the clock. The verdict had no relation to the case; "Torah
|
||||||
|
%%% references ground this interpretation (0 roots, 0 references
|
||||||
|
%%% examined)" was accepted on nothing — the calf, cast by the court
|
||||||
|
%%% itself. Now a judge CONSULTS: the application env `consult` names
|
||||||
|
%%% an {M, F} the pipeline registers (the Talmudic Engine behind it),
|
||||||
|
%%% and each seat on the panel performs one step of the dialectic —
|
||||||
|
%%% interpret, challenge, resolve, cycling by seat — on the case's
|
||||||
|
%%% roots and question. Resolved → accept, with the verses; a standing
|
||||||
|
%%% kushya → reject; teiku → abstain. No consult, or an engine that
|
||||||
|
%%% cannot be reached → abstain, and the reasoning says so. Dissent
|
||||||
|
%%% arises from the dialectic, never from a die.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(judge).
|
-module(judge).
|
||||||
@@ -20,24 +34,34 @@
|
|||||||
-include("sanhedrin.hrl").
|
-include("sanhedrin.hrl").
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([start_link/1, deliberate/2, stop/1]).
|
-export([start_link/1, start_link/2, deliberate/2, stop/1]).
|
||||||
|
|
||||||
|
%% Exposed for the suite
|
||||||
|
-export([step_for_seat/1, vote_from_consultation/3]).
|
||||||
|
|
||||||
%% gen_server callbacks
|
%% gen_server callbacks
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
-record(state, {
|
-record(state, {
|
||||||
judge_id :: reference(),
|
judge_id :: reference(),
|
||||||
|
seat :: pos_integer(),
|
||||||
case_data :: #sanhedrin_case{},
|
case_data :: #sanhedrin_case{},
|
||||||
vote :: #vote{} | undefined,
|
vote :: #vote{} | undefined,
|
||||||
panel_pid :: pid() | undefined
|
panel_pid :: pid() | undefined
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
-define(STEPS, [interpret, challenge, resolve]).
|
||||||
|
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
%% API
|
%% API
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
start_link(Case) ->
|
start_link(Case) ->
|
||||||
gen_server:start_link(?MODULE, Case, []).
|
start_link(Case, 1).
|
||||||
|
|
||||||
|
%% @doc Start a judge in seat N of its panel (1-based).
|
||||||
|
start_link(Case, Seat) ->
|
||||||
|
gen_server:start_link(?MODULE, {Case, Seat}, []).
|
||||||
|
|
||||||
%% @doc Instruct this judge to deliberate and send vote to PanelPid.
|
%% @doc Instruct this judge to deliberate and send vote to PanelPid.
|
||||||
-spec deliberate(pid(), pid()) -> ok.
|
-spec deliberate(pid(), pid()) -> ok.
|
||||||
@@ -49,28 +73,35 @@ deliberate(JudgePid, PanelPid) ->
|
|||||||
stop(JudgePid) ->
|
stop(JudgePid) ->
|
||||||
gen_server:stop(JudgePid, normal, 5000).
|
gen_server:stop(JudgePid, normal, 5000).
|
||||||
|
|
||||||
|
%% @doc The step of the dialectic a seat performs: 1 → interpret,
|
||||||
|
%% 2 → challenge, 3 → resolve, 4 → interpret, ...
|
||||||
|
-spec step_for_seat(pos_integer()) -> interpret | challenge | resolve.
|
||||||
|
step_for_seat(Seat) when is_integer(Seat), Seat >= 1 ->
|
||||||
|
lists:nth(((Seat - 1) rem length(?STEPS)) + 1, ?STEPS).
|
||||||
|
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
%% gen_server callbacks
|
%% gen_server callbacks
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
init(Case) ->
|
init({Case, Seat}) ->
|
||||||
JudgeId = make_ref(),
|
|
||||||
{ok, #state{
|
{ok, #state{
|
||||||
judge_id = JudgeId,
|
judge_id = make_ref(),
|
||||||
|
seat = Seat,
|
||||||
case_data = Case,
|
case_data = Case,
|
||||||
vote = undefined,
|
vote = undefined,
|
||||||
panel_pid = undefined
|
panel_pid = undefined
|
||||||
}}.
|
}};
|
||||||
|
init(Case) ->
|
||||||
|
init({Case, 1}).
|
||||||
|
|
||||||
handle_call(_Request, _From, State) ->
|
handle_call(_Request, _From, State) ->
|
||||||
{reply, {error, unknown_request}, State}.
|
{reply, {error, unknown_request}, State}.
|
||||||
|
|
||||||
handle_cast({deliberate, PanelPid}, State) ->
|
handle_cast({deliberate, PanelPid}, State) ->
|
||||||
Vote = perform_deliberation(State#state.judge_id, State#state.case_data),
|
Vote = perform_deliberation(State#state.judge_id, State#state.seat, State#state.case_data),
|
||||||
%% Send vote to panel
|
%% Send vote to panel
|
||||||
PanelPid ! {vote, Vote},
|
PanelPid ! {vote, Vote},
|
||||||
{noreply, State#state{vote = Vote, panel_pid = PanelPid}};
|
{noreply, State#state{vote = Vote, panel_pid = PanelPid}};
|
||||||
|
|
||||||
handle_cast(_Msg, State) ->
|
handle_cast(_Msg, State) ->
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
@@ -78,112 +109,112 @@ handle_info(_Info, State) ->
|
|||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
%% Internal — Deliberation logic
|
%% Internal — Deliberation
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
%% @doc Perform independent deliberation.
|
perform_deliberation(JudgeId, Seat, Case) ->
|
||||||
%%
|
Step = step_for_seat(Seat),
|
||||||
%% Phase 2: Each judge examines the case context (roots, torah_refs)
|
|
||||||
%% and produces an independent interpretation. In Phase 3, judges
|
|
||||||
%% will call the Talmudic Engine for deeper reasoning.
|
|
||||||
%%
|
|
||||||
%% The deliberation is non-deterministic by design — judges may
|
|
||||||
%% reach different conclusions from the same evidence.
|
|
||||||
perform_deliberation(JudgeId, Case) ->
|
|
||||||
Context = Case#sanhedrin_case.context,
|
Context = Case#sanhedrin_case.context,
|
||||||
Question = Case#sanhedrin_case.question,
|
|
||||||
|
|
||||||
%% Extract roots and references from context
|
|
||||||
Roots = maps:get(roots, Context, []),
|
Roots = maps:get(roots, Context, []),
|
||||||
TorahRefs = maps:get(torah_refs, Context, []),
|
Question = Case#sanhedrin_case.question,
|
||||||
|
Consultation = consult(Step, Case#sanhedrin_case.case_ref, Roots, Question, Context),
|
||||||
%% Generate interpretation based on case content
|
{Position, Reasoning, Refs, Verdict} = vote_from_consultation(Step, Consultation, length(Roots)),
|
||||||
{Position, Reasoning, Verdict} = interpret(Question, Roots, TorahRefs),
|
|
||||||
|
|
||||||
%% Build Torah references for this vote
|
|
||||||
VoteRefs = build_refs(TorahRefs),
|
|
||||||
|
|
||||||
#vote{
|
#vote{
|
||||||
judge_id = JudgeId,
|
judge_id = JudgeId,
|
||||||
position = Position,
|
position = Position,
|
||||||
reasoning = Reasoning,
|
reasoning = Reasoning,
|
||||||
torah_refs = VoteRefs,
|
torah_refs = build_refs(Refs),
|
||||||
verdict = Verdict,
|
verdict = Verdict,
|
||||||
cast_at = erlang:system_time(millisecond)
|
cast_at = erlang:system_time(millisecond)
|
||||||
}.
|
}.
|
||||||
|
|
||||||
%% @doc Generate an interpretation.
|
%% The consult callback: {M, F} in the sanhedrin application env,
|
||||||
%%
|
%% called as M:F(Step, CaseRef, Roots, Question, Context) and expected
|
||||||
%% Phase 2: Uses deterministic logic based on case content.
|
%% to answer {ok, #{status := binary(), text := binary(),
|
||||||
%% Each judge applies a slightly different weighting — simulated
|
%% verses := [{atom(), integer(), integer()}], kushya => binary()}}
|
||||||
%% by examining different aspects of the roots and references.
|
%% or {error, term()}. Anything it raises is caught: a judge that
|
||||||
%%
|
%% cannot consult abstains; it does not fall.
|
||||||
%% Phase 3: Will delegate to Talmudic Engine for real dialectical
|
consult(Step, CaseRef, Roots, Question, Context) ->
|
||||||
%% reasoning (interpret → challenge → resolve).
|
case application:get_env(sanhedrin, consult) of
|
||||||
interpret(Question, Roots, TorahRefs) ->
|
{ok, {M, F}} when is_atom(M), is_atom(F) ->
|
||||||
%% Base interpretation on root analysis
|
try M:F(Step, CaseRef, Roots, Question, Context) of
|
||||||
RootCount = length(Roots),
|
{ok, Map} when is_map(Map) -> {ok, Map};
|
||||||
RefCount = length(TorahRefs),
|
{error, Reason} -> {error, Reason};
|
||||||
|
Other -> {error, {bad_consultation, Other}}
|
||||||
%% Deterministic but varied deliberation:
|
catch
|
||||||
%% Judges use their unique ID (via process randomness) to
|
Class:Reason -> {error, {Class, Reason}}
|
||||||
%% introduce variation, simulating independent thought.
|
end;
|
||||||
Hash = erlang:phash2({Question, self(), erlang:monotonic_time()}),
|
_ ->
|
||||||
|
{error, no_consult_registered}
|
||||||
case Hash rem 6 of
|
|
||||||
0 ->
|
|
||||||
{<<"The roots indicate acceptance — the action aligns with Torah principle">>,
|
|
||||||
format_reasoning(<<"Root analysis supports this interpretation">>, RootCount, RefCount),
|
|
||||||
accept};
|
|
||||||
1 ->
|
|
||||||
{<<"The roots indicate rejection — the action contradicts specification">>,
|
|
||||||
format_reasoning(<<"Root tension detected — contradicting principles found">>, RootCount, RefCount),
|
|
||||||
reject};
|
|
||||||
2 ->
|
|
||||||
{<<"The roots support acceptance with qualification">>,
|
|
||||||
format_reasoning(<<"Conditional acceptance — roots align but context requires care">>, RootCount, RefCount),
|
|
||||||
accept};
|
|
||||||
3 ->
|
|
||||||
{<<"Insufficient grounding — the question cannot be resolved from available sources">>,
|
|
||||||
format_reasoning(<<"Teiku — the evidence is insufficient for determination">>, RootCount, RefCount),
|
|
||||||
abstain};
|
|
||||||
4 ->
|
|
||||||
{<<"The references confirm the action is consistent with specification">>,
|
|
||||||
format_reasoning(<<"Torah references ground this interpretation">>, RootCount, RefCount),
|
|
||||||
accept};
|
|
||||||
5 ->
|
|
||||||
{<<"The references reveal a tension that prohibits this action">>,
|
|
||||||
format_reasoning(<<"Prohibition detected via root-reference cross-analysis">>, RootCount, RefCount),
|
|
||||||
reject}
|
|
||||||
end.
|
end.
|
||||||
|
|
||||||
format_reasoning(Base, RootCount, RefCount) ->
|
%% @doc The vote a consultation yields. Exposed for the suite.
|
||||||
|
-spec vote_from_consultation(interpret | challenge | resolve,
|
||||||
|
{ok, map()} | {error, term()},
|
||||||
|
non_neg_integer()) ->
|
||||||
|
{binary(), binary(), list(), accept | reject | abstain}.
|
||||||
|
vote_from_consultation(Step, {ok, Map}, RootCount) ->
|
||||||
|
Status = maps:get(status, Map, <<"teiku">>),
|
||||||
|
Text = maps:get(text, Map, <<>>),
|
||||||
|
Verses = maps:get(verses, Map, []),
|
||||||
|
Grounding = maps:get(grounding, Map, []),
|
||||||
|
case Status of
|
||||||
|
<<"resolved">> when Verses =:= [], Grounding =:= [] ->
|
||||||
|
%% The engine says "resolved" over an empty trace — no verse,
|
||||||
|
%% no mitzvah. That is a reading that stands on nothing, and
|
||||||
|
%% a vote to accept it would be the calf (avodah_zarah.pl
|
||||||
|
%% Form 2: the uncoupled output). Abstain, and say why.
|
||||||
|
{<<"Teiku — the reading is coupled to no verse and no mitzvah">>,
|
||||||
|
reasoning(Step, <<"resolved on an empty trace; nothing to stand on">>, RootCount, Verses),
|
||||||
|
[], abstain};
|
||||||
|
<<"resolved">> ->
|
||||||
|
{Text,
|
||||||
|
reasoning(Step, <<"resolved">>, RootCount, Verses),
|
||||||
|
Verses, accept};
|
||||||
|
<<"challenged">> ->
|
||||||
|
Kushya = maps:get(kushya, Map, <<"a kushya stands">>),
|
||||||
|
{Kushya,
|
||||||
|
reasoning(Step, <<"a kushya stands against the reading">>, RootCount, Verses),
|
||||||
|
Verses, reject};
|
||||||
|
<<"halt">> ->
|
||||||
|
{Text,
|
||||||
|
reasoning(Step, <<"the halt rules fired">>, RootCount, Verses),
|
||||||
|
Verses, reject};
|
||||||
|
_ ->
|
||||||
|
{<<"Teiku — the question is not resolved from the specification">>,
|
||||||
|
reasoning(Step, <<"teiku">>, RootCount, Verses),
|
||||||
|
Verses, abstain}
|
||||||
|
end;
|
||||||
|
vote_from_consultation(Step, {error, Reason}, RootCount) ->
|
||||||
|
{<<"Teiku — the specification was not consulted">>,
|
||||||
|
iolist_to_binary([step_name(Step), <<": no consultation (">>,
|
||||||
|
io_lib:format("~p", [Reason]), <<") — ">>,
|
||||||
|
integer_to_binary(RootCount), <<" roots given; abstaining">>]),
|
||||||
|
[], abstain}.
|
||||||
|
|
||||||
|
reasoning(Step, What, RootCount, Verses) ->
|
||||||
iolist_to_binary([
|
iolist_to_binary([
|
||||||
Base,
|
step_name(Step), <<" via the Talmudic Engine: ">>, What,
|
||||||
<<" (">>,
|
<<" (">>, integer_to_binary(RootCount), <<" roots, ">>,
|
||||||
integer_to_binary(RootCount),
|
integer_to_binary(length(Verses)), <<" verses)">>
|
||||||
<<" roots, ">>,
|
|
||||||
integer_to_binary(RefCount),
|
|
||||||
<<" references examined)">>
|
|
||||||
]).
|
]).
|
||||||
|
|
||||||
|
step_name(interpret) -> <<"interpret">>;
|
||||||
|
step_name(challenge) -> <<"challenge">>;
|
||||||
|
step_name(resolve) -> <<"resolve">>.
|
||||||
|
|
||||||
build_refs(TorahRefs) ->
|
build_refs(TorahRefs) ->
|
||||||
lists:map(
|
lists:map(
|
||||||
fun(Ref) when is_tuple(Ref) ->
|
fun({Book, Chapter, Verse}) ->
|
||||||
case Ref of
|
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
|
||||||
{Book, Chapter, Verse} ->
|
|
||||||
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
|
|
||||||
_ ->
|
|
||||||
#torah_ref{book = unknown, chapter = 0, verse = 0}
|
|
||||||
end;
|
|
||||||
(Ref) when is_map(Ref) ->
|
(Ref) when is_map(Ref) ->
|
||||||
#torah_ref{
|
#torah_ref{
|
||||||
book = maps:get(book, Ref, unknown),
|
book = maps:get(book, Ref, unknown),
|
||||||
chapter = maps:get(chapter, Ref, 0),
|
chapter = maps:get(chapter, Ref, 0),
|
||||||
verse = maps:get(verse, Ref, 0)
|
verse = maps:get(verse, Ref, 0)
|
||||||
};
|
};
|
||||||
(_) ->
|
(_) ->
|
||||||
#torah_ref{book = unknown, chapter = 0, verse = 0}
|
#torah_ref{book = unknown, chapter = 0, verse = 0}
|
||||||
end,
|
end,
|
||||||
TorahRefs
|
TorahRefs
|
||||||
).
|
).
|
||||||
+13
-6
@@ -207,15 +207,21 @@ compute_majority_minority([]) ->
|
|||||||
torah_refs => [],
|
torah_refs => [],
|
||||||
vote_count => 0}};
|
vote_count => 0}};
|
||||||
compute_majority_minority(Votes) ->
|
compute_majority_minority(Votes) ->
|
||||||
%% Count accepts vs rejects
|
|
||||||
Accepts = [V || V <- Votes, V#vote.verdict == accept],
|
Accepts = [V || V <- Votes, V#vote.verdict == accept],
|
||||||
Rejects = [V || V <- Votes, V#vote.verdict == reject],
|
Rejects = [V || V <- Votes, V#vote.verdict == reject],
|
||||||
Abstains = [V || V <- Votes, V#vote.verdict == abstain],
|
Abstains = [V || V <- Votes, V#vote.verdict == abstain],
|
||||||
|
NA = length(Accepts), NR = length(Rejects), NB = length(Abstains),
|
||||||
|
|
||||||
|
%% The majority is the verdict with the most votes — abstentions
|
||||||
|
%% count (tablet 55: live, one accept "won" against two
|
||||||
|
%% abstentions, and the reply showed majority 1, minority 2). An
|
||||||
|
%% abstaining plurality, or an accept/reject tie, is teiku
|
||||||
|
%% (determine_outcome reads the majority's verdict).
|
||||||
{MajVotes, MinVotes} =
|
{MajVotes, MinVotes} =
|
||||||
case length(Accepts) >= length(Rejects) of
|
if
|
||||||
true -> {Accepts, Rejects ++ Abstains};
|
NA > NR, NA >= NB -> {Accepts, Rejects ++ Abstains};
|
||||||
false -> {Rejects, Accepts ++ Abstains}
|
NR > NA, NR >= NB -> {Rejects, Accepts ++ Abstains};
|
||||||
|
true -> {Abstains, Accepts ++ Rejects}
|
||||||
end,
|
end,
|
||||||
|
|
||||||
MajSample = case MajVotes of
|
MajSample = case MajVotes of
|
||||||
@@ -307,9 +313,10 @@ store_all_opinions(CaseRef, Votes, Majority) ->
|
|||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
convene_judges(Case, Size) ->
|
convene_judges(Case, Size) ->
|
||||||
|
%% Seat N performs step N of the dialectic (judge:step_for_seat/1).
|
||||||
lists:map(
|
lists:map(
|
||||||
fun(_N) ->
|
fun(Seat) ->
|
||||||
{ok, Pid} = judge:start_link(Case),
|
{ok, Pid} = judge:start_link(Case, Seat),
|
||||||
Pid
|
Pid
|
||||||
end,
|
end,
|
||||||
lists:seq(1, Size)
|
lists:seq(1, Size)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
-include_lib("eunit/include/eunit.hrl").
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
-include("sanhedrin.hrl").
|
-include("sanhedrin.hrl").
|
||||||
|
|
||||||
|
%% The consult callback the tablet-55 tests register (judge:consult reads {M,F}).
|
||||||
|
-export([fake_consult/5]).
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% Test fixture — start/stop the application for each test
|
%% Test fixture — start/stop the application for each test
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
@@ -58,6 +61,16 @@ halt_clear_test() ->
|
|||||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||||
cleanup(ok).
|
cleanup(ok).
|
||||||
|
|
||||||
|
%% Tablet 55: a non-map context is refused, and the monitor survives it.
|
||||||
|
halt_malformed_context_test() ->
|
||||||
|
{ok, _} = application:ensure_all_started(sanhedrin),
|
||||||
|
Sides = [#{<<"position">> => <<"a">>, <<"evidence">> => [<<"x">>]}],
|
||||||
|
Case = make_case_with_context(simple, <<"question">>, Sides),
|
||||||
|
?assertMatch({halt, malformed_case, _}, halt_monitor:check_case(Case)),
|
||||||
|
%% still standing, and still judging
|
||||||
|
?assertEqual(clear, halt_monitor:check_case(make_case(simple, <<"q">>))),
|
||||||
|
ok.
|
||||||
|
|
||||||
halt_avodah_zarah_test() ->
|
halt_avodah_zarah_test() ->
|
||||||
setup(),
|
setup(),
|
||||||
Case = make_case_with_context(simple, <<"question">>,
|
Case = make_case_with_context(simple, <<"question">>,
|
||||||
@@ -372,6 +385,92 @@ judge_deliberate_sends_vote_test() ->
|
|||||||
end,
|
end,
|
||||||
judge:stop(JudgePid).
|
judge:stop(JudgePid).
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% Tablet 55 — the judge consults; it does not roll dice
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
judge_seats_cycle_the_dialectic_test() ->
|
||||||
|
?assertEqual(interpret, judge:step_for_seat(1)),
|
||||||
|
?assertEqual(challenge, judge:step_for_seat(2)),
|
||||||
|
?assertEqual(resolve, judge:step_for_seat(3)),
|
||||||
|
?assertEqual(interpret, judge:step_for_seat(4)),
|
||||||
|
?assertEqual(challenge, judge:step_for_seat(23)),
|
||||||
|
?assertEqual(challenge, judge:step_for_seat(71)).
|
||||||
|
|
||||||
|
judge_without_consult_abstains_test() ->
|
||||||
|
application:unset_env(sanhedrin, consult),
|
||||||
|
Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>]}),
|
||||||
|
{ok, JudgePid} = judge:start_link(Case, 1),
|
||||||
|
judge:deliberate(JudgePid, self()),
|
||||||
|
receive
|
||||||
|
{vote, Vote} ->
|
||||||
|
?assertEqual(abstain, Vote#vote.verdict),
|
||||||
|
?assertEqual([], Vote#vote.torah_refs),
|
||||||
|
?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"no consultation">>))
|
||||||
|
after 5000 -> ?assert(false)
|
||||||
|
end,
|
||||||
|
judge:stop(JudgePid).
|
||||||
|
|
||||||
|
judge_consult_resolved_accepts_with_verses_test() ->
|
||||||
|
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
|
||||||
|
Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>], fake => resolved}),
|
||||||
|
{ok, JudgePid} = judge:start_link(Case, 1),
|
||||||
|
judge:deliberate(JudgePid, self()),
|
||||||
|
receive
|
||||||
|
{vote, Vote} ->
|
||||||
|
?assertEqual(accept, Vote#vote.verdict),
|
||||||
|
?assertEqual(<<"the reading">>, Vote#vote.position),
|
||||||
|
?assertMatch([#torah_ref{book = bereishit, chapter = 1, verse = 1}], Vote#vote.torah_refs),
|
||||||
|
?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"interpret via the Talmudic Engine">>))
|
||||||
|
after 5000 -> ?assert(false)
|
||||||
|
end,
|
||||||
|
judge:stop(JudgePid),
|
||||||
|
application:unset_env(sanhedrin, consult).
|
||||||
|
|
||||||
|
judge_consult_raises_abstains_and_stands_test() ->
|
||||||
|
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
|
||||||
|
Case = make_case_with_context(simple, <<"q">>, #{fake => raise}),
|
||||||
|
{ok, JudgePid} = judge:start_link(Case, 2),
|
||||||
|
judge:deliberate(JudgePid, self()),
|
||||||
|
receive
|
||||||
|
{vote, Vote} -> ?assertEqual(abstain, Vote#vote.verdict)
|
||||||
|
after 5000 -> ?assert(false)
|
||||||
|
end,
|
||||||
|
?assert(is_process_alive(JudgePid)),
|
||||||
|
judge:stop(JudgePid),
|
||||||
|
application:unset_env(sanhedrin, consult).
|
||||||
|
|
||||||
|
%% A panel of three where the dialectic finds a kushya at seat 2:
|
||||||
|
%% interpret → resolved, challenge → challenged, resolve → resolved.
|
||||||
|
%% Majority 2 accept, minority 1 reject — the minority preserved.
|
||||||
|
panel_of_three_dissent_from_the_dialectic_test() ->
|
||||||
|
setup(),
|
||||||
|
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
|
||||||
|
Case = make_case_with_context(monetary, <<"q">>, #{roots => [<<"br-a">>], fake => dialectic}),
|
||||||
|
{ok, CaseRef} = escalation:submit_case(Case),
|
||||||
|
timer:sleep(600),
|
||||||
|
{ok, Decision} = clerk:get_decision(CaseRef),
|
||||||
|
?assertEqual(3, Decision#decision.panel_size),
|
||||||
|
?assertEqual(accepted, Decision#decision.outcome),
|
||||||
|
?assertEqual(2, maps:get(vote_count, Decision#decision.majority)),
|
||||||
|
?assertEqual(1, maps:get(vote_count, Decision#decision.minority)),
|
||||||
|
?assertEqual(<<"but the verse says otherwise">>, maps:get(position, Decision#decision.minority)),
|
||||||
|
application:unset_env(sanhedrin, consult),
|
||||||
|
cleanup(ok).
|
||||||
|
|
||||||
|
%% The fake consultation the tests register. Step-aware for `dialectic`.
|
||||||
|
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := raise}) ->
|
||||||
|
erlang:error(engine_exploded);
|
||||||
|
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := resolved}) ->
|
||||||
|
{ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}};
|
||||||
|
fake_consult(challenge, _Ref, _Roots, _Q, #{fake := dialectic}) ->
|
||||||
|
{ok, #{status => <<"challenged">>, text => <<>>, kushya => <<"but the verse says otherwise">>,
|
||||||
|
verses => [{devarim, 6, 4}]}};
|
||||||
|
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := dialectic}) ->
|
||||||
|
{ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}};
|
||||||
|
fake_consult(_Step, _Ref, _Roots, _Q, _Ctx) ->
|
||||||
|
{error, no_fake}.
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% Panel majority/minority computation tests
|
%% Panel majority/minority computation tests
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
@@ -407,6 +506,37 @@ majority_verdict_reject_test() ->
|
|||||||
{Maj, _Min} = panel:compute_majority_minority(Votes),
|
{Maj, _Min} = panel:compute_majority_minority(Votes),
|
||||||
?assertEqual(reject, maps:get(verdict, Maj)).
|
?assertEqual(reject, maps:get(verdict, Maj)).
|
||||||
|
|
||||||
|
%% Tablet 55: abstentions count. One accept against two abstentions is
|
||||||
|
%% not an accepting majority — it is teiku.
|
||||||
|
majority_abstaining_plurality_is_teiku_test() ->
|
||||||
|
Votes = [make_vote(accept), make_vote(abstain), make_vote(abstain)],
|
||||||
|
{Maj, Min} = panel:compute_majority_minority(Votes),
|
||||||
|
?assertEqual(abstain, maps:get(verdict, Maj)),
|
||||||
|
?assertEqual(2, maps:get(vote_count, Maj)),
|
||||||
|
?assertEqual(1, maps:get(vote_count, Min)),
|
||||||
|
?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)).
|
||||||
|
|
||||||
|
majority_accept_reject_tie_is_teiku_test() ->
|
||||||
|
Votes = [make_vote(accept), make_vote(reject)],
|
||||||
|
{Maj, _Min} = panel:compute_majority_minority(Votes),
|
||||||
|
?assertEqual(abstain, maps:get(verdict, Maj)),
|
||||||
|
?assertEqual(0, maps:get(vote_count, Maj)).
|
||||||
|
|
||||||
|
%% Tablet 55: "resolved" over an empty trace — no verse, no mitzvah —
|
||||||
|
%% is nothing to stand on; the judge abstains rather than accept.
|
||||||
|
judge_resolved_on_empty_trace_abstains_test() ->
|
||||||
|
{Pos, Reason, Refs, Verdict} =
|
||||||
|
judge:vote_from_consultation(interpret,
|
||||||
|
{ok, #{status => <<"resolved">>, text => <<"reading(0,0)">>, verses => [], grounding => []}}, 5),
|
||||||
|
?assertEqual(abstain, Verdict),
|
||||||
|
?assertEqual([], Refs),
|
||||||
|
?assertMatch({_, _}, binary:match(Pos, <<"Teiku">>)),
|
||||||
|
?assertMatch({_, _}, binary:match(Reason, <<"empty trace">>)),
|
||||||
|
%% a mitzvah without a verse is still ground
|
||||||
|
{_, _, _, V2} = judge:vote_from_consultation(interpret,
|
||||||
|
{ok, #{status => <<"resolved">>, text => <<"r">>, verses => [], grounding => [<<"mitzvah(5)">>]}}, 1),
|
||||||
|
?assertEqual(accept, V2).
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% determine_outcome tests — accepted, rejected, teiku reachable
|
%% determine_outcome tests — accepted, rejected, teiku reachable
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ if [[ "${DOORPOST_SKIP:-0}" != "1" ]]; then
|
|||||||
python3 -I scripts/doorpost_check.py || exit 1
|
python3 -I scripts/doorpost_check.py || exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The engine's verse facts are derived from L1 (scripts/build_engine_verses.py);
|
||||||
|
# an image whose facts lag the scroll reasons over a scroll that is not
|
||||||
|
# the one the pipeline reads. Regenerate and commit, never skip.
|
||||||
|
python3 -I scripts/build_engine_verses.py --check || exit 1
|
||||||
|
|
||||||
if ! docker buildx version >/dev/null 2>&1; then
|
if ! docker buildx version >/dev/null 2>&1; then
|
||||||
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
|
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""build_engine_verses.py — the Talmudic Engine's verse facts, derived from L1.
|
||||||
|
|
||||||
|
Architecture §3.4: verse(Book, Chapter, Verse, HebrewRoots, Gloss) is "the
|
||||||
|
reasoning layer's view of the specification — not a replacement for L1".
|
||||||
|
Until 2026-10-19 that view was 32 hand-written verses, so any root the
|
||||||
|
engine knew lit the same handful and every reading cited the same list.
|
||||||
|
This script writes talmudic_engine/src/specification/l1_verses.pl from
|
||||||
|
l1/torah — every verse, its roots as the engine names them
|
||||||
|
(Hebrew shoresh → letter names, finals folded: ברא → bet_resh_aleph), the
|
||||||
|
consonantal text as the gloss — and records the SHA-256 of the L1 files it
|
||||||
|
read in the header, so the facts can be checked against the scroll.
|
||||||
|
|
||||||
|
scripts/build_engine_verses.py # write the facts
|
||||||
|
scripts/build_engine_verses.py --check # exit 1 if the facts do not match L1 as it stands
|
||||||
|
|
||||||
|
Devarim 4:2 — nothing added, nothing taken: the engine reads the scroll's
|
||||||
|
roots, not the builder's memory of them.
|
||||||
|
"""
|
||||||
|
import argparse, hashlib, json, os, sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
L1 = os.path.join(ROOT, "l1", "torah")
|
||||||
|
OUT = os.path.join(ROOT, "talmudic_engine", "src", "specification", "l1_verses.pl")
|
||||||
|
BOOKS = ["bereishit", "shemot", "vayikra", "bamidbar", "devarim"]
|
||||||
|
|
||||||
|
NAMES = {
|
||||||
|
"א": "aleph", "ב": "bet", "ג": "gimel", "ד": "dalet", "ה": "he", "ו": "vav", "ז": "zayin",
|
||||||
|
"ח": "chet", "ט": "tet", "י": "yod", "כ": "kaf", "ך": "kaf", "ל": "lamed", "מ": "mem", "ם": "mem",
|
||||||
|
"נ": "nun", "ן": "nun", "ס": "samekh", "ע": "ayin", "פ": "pe", "ף": "pe", "צ": "tsadi", "ץ": "tsadi",
|
||||||
|
"ק": "qof", "ר": "resh", "ש": "shin", "ת": "tav",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def root_atom(shoresh):
|
||||||
|
if not shoresh:
|
||||||
|
return None
|
||||||
|
names = [NAMES.get(c) for c in shoresh]
|
||||||
|
if not names or any(n is None for n in names):
|
||||||
|
return None
|
||||||
|
return "_".join(names)
|
||||||
|
|
||||||
|
|
||||||
|
def quote(s):
|
||||||
|
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def verse_files():
|
||||||
|
out = []
|
||||||
|
for book in BOOKS:
|
||||||
|
bdir = os.path.join(L1, book)
|
||||||
|
for ch in sorted((d for d in os.listdir(bdir) if d.isdigit()), key=int):
|
||||||
|
cdir = os.path.join(bdir, ch)
|
||||||
|
for f in sorted((f for f in os.listdir(cdir) if f.endswith(".json") and f[:-5].isdigit()), key=lambda x: int(x[:-5])):
|
||||||
|
out.append((book, int(ch), int(f.split(".")[0]), os.path.join(cdir, f)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
h = hashlib.sha256()
|
||||||
|
facts = []
|
||||||
|
nverses = nroots = 0
|
||||||
|
for book, ch, vs, path in verse_files():
|
||||||
|
data = open(path, "rb").read()
|
||||||
|
h.update(f"{book}/{ch}/{vs}\n".encode())
|
||||||
|
h.update(data)
|
||||||
|
d = json.loads(data)
|
||||||
|
roots = []
|
||||||
|
for w in d.get("words", []):
|
||||||
|
a = root_atom(w.get("shoresh"))
|
||||||
|
if a and a not in roots:
|
||||||
|
roots.append(a)
|
||||||
|
gloss = " ".join(w.get("consonants", "") for w in d.get("words", []) if w.get("consonants"))
|
||||||
|
facts.append(f"verse({book}, {ch}, {vs}, [{', '.join(roots)}], {quote(gloss)}).")
|
||||||
|
nverses += 1
|
||||||
|
nroots += len(roots)
|
||||||
|
header = f"""%% l1_verses.pl — GENERATED by scripts/build_engine_verses.py from l1/torah.
|
||||||
|
%% Do not edit: regenerate. verse(Book, Chapter, Verse, Roots, Gloss) for
|
||||||
|
%% every verse of L1; Roots are the engine's letter-name atoms derived from
|
||||||
|
%% each word's shoresh (finals folded), once per verse in word order; Gloss
|
||||||
|
%% is the consonantal text. Included by torah_text.pl.
|
||||||
|
%%
|
||||||
|
%% l1_sha256: {h.hexdigest()}
|
||||||
|
%% verses: {nverses} root_occurrences: {nroots}
|
||||||
|
"""
|
||||||
|
return header + "\n".join(facts) + "\n", h.hexdigest(), nverses
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--check", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
text, digest, n = build()
|
||||||
|
if a.check:
|
||||||
|
try:
|
||||||
|
current = open(OUT, encoding="utf-8").read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"engine verses: {OUT} missing — run scripts/build_engine_verses.py", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if current != text:
|
||||||
|
print(f"engine verses: {OUT} does not match L1 (l1_sha256 now {digest[:12]}…) — regenerate", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"engine verses: match L1 ({n} verses, l1_sha256 {digest[:12]}…)")
|
||||||
|
return
|
||||||
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
print(f"wrote {OUT}: {n} verses, l1_sha256 {digest[:12]}…")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -132,3 +132,66 @@ make. The fence held throughout — each forgery was stripped and named by
|
|||||||
a verdict the builder did not write — which is the design proving itself
|
a verdict the builder did not write — which is the design proving itself
|
||||||
on its builder: the check has to stand outside the model, because the
|
on its builder: the check has to stand outside the model, because the
|
||||||
model's own intention is subject to the same pull it is meant to catch.
|
model's own intention is subject to the same pull it is meant to catch.
|
||||||
|
|
||||||
|
## Tablet 55 — the row that was a label (2026-10-19)
|
||||||
|
|
||||||
|
Tamid promises `POST /chatzer/sanhedrin`: a case, optional sides, a
|
||||||
|
panel of three, a majority of two and a minority of one with their
|
||||||
|
sources. The row had stood since the endpoint was written. The build
|
||||||
|
state carried a line beside it — "never verified on prod" — for months.
|
||||||
|
On 2026-10-19 the builder sent the spec's own example request to the
|
||||||
|
live system, as the roadmap's rule asks: a spec row is a claim to test,
|
||||||
|
not a label.
|
||||||
|
|
||||||
|
The first request answered 500. The router had handed the `sides` list
|
||||||
|
to the Sanhedrin as the case's *context*, where the halt monitor reads a
|
||||||
|
map of `authority_source` and `action`; `maps:get` raised, and the halt
|
||||||
|
monitor and the escalation server died with the caller's input. The
|
||||||
|
supervisor restarted them; health stayed 8/8 the whole time. A gate that
|
||||||
|
can be felled by what it is handed is not a gate, and a health check
|
||||||
|
that cannot see the gate fall is not watching it. The router then
|
||||||
|
pattern-matched `{:ok, decision}` on an error and turned the refusal
|
||||||
|
into a 500 of its own.
|
||||||
|
|
||||||
|
The second request, without sides, answered 200 — and the answer was
|
||||||
|
hollow. A panel of one, not three. Field names the spec does not use.
|
||||||
|
An outcome of "accepted" on zero roots and zero references examined,
|
||||||
|
under the sentence "Torah references ground this interpretation". That
|
||||||
|
is the shape the bedikah names golden_calf when a model says it: a
|
||||||
|
confident claim standing on nothing. Here the system said it in its own
|
||||||
|
voice, through its own inner court. The complexity hint was ignored, no
|
||||||
|
panel manna was spent, and nothing was engraved in L3.
|
||||||
|
|
||||||
|
Fixed in this tablet: the halt monitor refuses a non-map context as
|
||||||
|
`malformed_case` and stays standing; the router carries the sides inside
|
||||||
|
the context map, checks their shape at the kiyor (422), and answers a
|
||||||
|
Sanhedrin refusal (422 SANHEDRIN_HALTED) or failure (503
|
||||||
|
SANHEDRIN_UNAVAILABLE) instead of crashing. The spec's own example now
|
||||||
|
deliberates, and the test asserts that the gate is still there
|
||||||
|
afterwards.
|
||||||
|
|
||||||
|
Not fixed in the first commit, and recorded here so it was not mistaken
|
||||||
|
for fixed: the panel of one, the empty references under a
|
||||||
|
grounded-sounding sentence, the ignored hint, the unspent manna, the
|
||||||
|
unrecorded case. The row in Tamid was still larger than the thing
|
||||||
|
behind it.
|
||||||
|
|
||||||
|
Fixed in the second commit, the same day. The judge had been choosing
|
||||||
|
one of six canned positions by a hash over the question, its own pid
|
||||||
|
and the clock — "non-deterministic by design", the comment said, with
|
||||||
|
a note that a later phase would consult the engine. The phase never
|
||||||
|
came; the dice stayed. Now a judge consults: the pipeline registers
|
||||||
|
the Talmudic Engine as what a judge asks, and each seat on a panel
|
||||||
|
performs one step of the dialectic over the case's roots — interpret,
|
||||||
|
challenge, resolve, cycling by seat. A resolved reading is a vote to
|
||||||
|
accept, with its verses; a standing kushya is a vote to reject; teiku
|
||||||
|
is abstention; and a judge with nothing to consult abstains and says
|
||||||
|
so. Dissent arises from the dialectic, not from a die. The chatzer
|
||||||
|
path hears the question before submitting it, as pe'ah does — scope,
|
||||||
|
verse, Shoresh Engine, roots — honours the complexity hint, spends one
|
||||||
|
unit of the day's panel manna, engraves the decision and its minority
|
||||||
|
in L3, and answers in the Tamid's own shape. The lesson is tablet
|
||||||
|
37's again — a spec row is a claim to test — with one thing added: the
|
||||||
|
system's own inner court is held to the same check as a model at the
|
||||||
|
gate. When the Sanhedrin accepts on nothing, it is the calf, whoever
|
||||||
|
cast it.
|
||||||
@@ -45,6 +45,24 @@ process_request(request(resolve, CaseRef, Domain, Roots, Question), Response) :-
|
|||||||
; Response = response(error, no_resolution, CaseRef)
|
; Response = response(error, no_resolution, CaseRef)
|
||||||
).
|
).
|
||||||
|
|
||||||
|
%% The challenge step of the dialectic, on its own (tablet 55: the
|
||||||
|
%% Sanhedrin's second seat asks it; until now no handler answered and
|
||||||
|
%% the socket fell silent). Interpret, then look for a kushya against
|
||||||
|
%% the interpretation: one stands → resolved_challenged, the kushya
|
||||||
|
%% named; none → no_challenge, the interpretation as read.
|
||||||
|
process_request(request(challenge, CaseRef, Domain, Roots, Question), Response) :-
|
||||||
|
Case = case(CaseRef, Domain, Roots, Question),
|
||||||
|
( interpret(Case, Interpretation)
|
||||||
|
-> ( challenge(Interpretation, Kushya)
|
||||||
|
-> Response = response(ok, resolved_challenged,
|
||||||
|
result(interpretation(Interpretation),
|
||||||
|
challenge(Kushya)))
|
||||||
|
; format_interpretation(Interpretation, response(ok, interpreted, Result)),
|
||||||
|
Response = response(ok, no_challenge, Result)
|
||||||
|
)
|
||||||
|
; Response = response(error, no_interpretation, CaseRef)
|
||||||
|
).
|
||||||
|
|
||||||
process_request(request(halt_check, Action), Response) :-
|
process_request(request(halt_check, Action), Response) :-
|
||||||
format_halt_check(Action, Response).
|
format_halt_check(Action, Response).
|
||||||
|
|
||||||
|
|||||||
@@ -153,11 +153,15 @@ read_coupling_term(Str, Term) :-
|
|||||||
%% JSON format (Construction §3.3):
|
%% JSON format (Construction §3.3):
|
||||||
%% { "status": "resolved", "interpretation": { ... } }
|
%% { "status": "resolved", "interpretation": { ... } }
|
||||||
|
|
||||||
%% Interpreted response
|
%% Interpreted response — and the same shape under the names the
|
||||||
response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
|
%% resolve and challenge steps give it when the reading stands
|
||||||
verses(Verses), reading(Reading),
|
%% (tablet 55: resolved_accepted had no clause, so a resolve that
|
||||||
confidence(Conf))),
|
%% succeeded came back UNEXPECTED_RESPONSE; no_challenge is new).
|
||||||
|
response_to_json(response(ok, Kind, result(ref(Ref), mitzvot(Mitzvot),
|
||||||
|
verses(Verses), reading(Reading),
|
||||||
|
confidence(Conf))),
|
||||||
Dict) :-
|
Dict) :-
|
||||||
|
memberchk(Kind, [interpreted, resolved_accepted, no_challenge]),
|
||||||
maplist(term_to_string, Mitzvot, MitzvotStrs),
|
maplist(term_to_string, Mitzvot, MitzvotStrs),
|
||||||
maplist(term_to_string, Verses, VerseStrs),
|
maplist(term_to_string, Verses, VerseStrs),
|
||||||
term_to_string(Reading, ReadingStr),
|
term_to_string(Reading, ReadingStr),
|
||||||
@@ -171,21 +175,52 @@ response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
|
|||||||
confidence: ConfStr},
|
confidence: ConfStr},
|
||||||
challenge: null}.
|
challenge: null}.
|
||||||
|
|
||||||
%% Challenged response
|
%% Challenged response — the interpretation's verses and mitzvot are
|
||||||
|
%% carried, so a judge rejecting on the kushya still cites its ground.
|
||||||
response_to_json(response(ok, resolved_challenged,
|
response_to_json(response(ok, resolved_challenged,
|
||||||
result(interpretation(Interp), challenge(Kushya))),
|
result(interpretation(Interp), challenge(Kushya))),
|
||||||
Dict) :-
|
Dict) :-
|
||||||
|
Interp = interpretation(_Ref, Mitzvot, Verses, _Reading, _Conf, _Trace),
|
||||||
|
maplist(term_to_string, Mitzvot, MitzvotStrs),
|
||||||
|
maplist(term_to_string, Verses, VerseStrs),
|
||||||
term_to_string(Interp, InterpStr),
|
term_to_string(Interp, InterpStr),
|
||||||
term_to_string(Kushya, KushyaStr),
|
term_to_string(Kushya, KushyaStr),
|
||||||
Dict = json{status: "challenged",
|
Dict = json{status: "challenged",
|
||||||
interpretation: json{text: InterpStr,
|
interpretation: json{text: InterpStr,
|
||||||
prolog_form: InterpStr,
|
prolog_form: InterpStr,
|
||||||
grounding: [],
|
grounding: MitzvotStrs,
|
||||||
|
verses: VerseStrs,
|
||||||
confidence: "medium"},
|
confidence: "medium"},
|
||||||
challenge: json{kushya: KushyaStr,
|
challenge: json{kushya: KushyaStr,
|
||||||
contradicting: "",
|
contradicting: "",
|
||||||
strength: "moderate"}}.
|
strength: "moderate"}}.
|
||||||
|
|
||||||
|
%% Synthesized resolution: both readings stand on common ground.
|
||||||
|
response_to_json(response(ok, resolved_synthesized,
|
||||||
|
result(common(Common), verses(Verses), both_sides(Both))),
|
||||||
|
Dict) :-
|
||||||
|
maplist(term_to_string, Common, CommonStrs),
|
||||||
|
maplist(term_to_string, Verses, VerseStrs),
|
||||||
|
term_to_string(Both, BothStr),
|
||||||
|
Dict = json{status: "resolved",
|
||||||
|
interpretation: json{text: BothStr,
|
||||||
|
prolog_form: BothStr,
|
||||||
|
grounding: CommonStrs,
|
||||||
|
verses: VerseStrs,
|
||||||
|
confidence: "medium"},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
|
%% No interpretation possible (from resolve)
|
||||||
|
response_to_json(response(ok, no_interpretation, result(ref(Ref))), Dict) :-
|
||||||
|
atom_string(Ref, RefStr),
|
||||||
|
Dict = json{status: "teiku",
|
||||||
|
interpretation: json{text: "No interpretation found",
|
||||||
|
prolog_form: RefStr,
|
||||||
|
grounding: [],
|
||||||
|
verses: [],
|
||||||
|
confidence: "low"},
|
||||||
|
challenge: null}.
|
||||||
|
|
||||||
%% Teiku response
|
%% Teiku response
|
||||||
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
|
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
|
||||||
term_to_string(Record, RecordStr),
|
term_to_string(Record, RecordStr),
|
||||||
|
|||||||
@@ -111,16 +111,45 @@ find_relevant_mitzvot_traced(case(_Ref, Domain, Roots, _Question), Mitzvot, Coup
|
|||||||
find_relevant_verses(case(Ref, Domain, Roots, Question), Verses) :-
|
find_relevant_verses(case(Ref, Domain, Roots, Question), Verses) :-
|
||||||
find_relevant_verses_traced(case(Ref, Domain, Roots, Question), Verses, _).
|
find_relevant_verses_traced(case(Ref, Domain, Roots, Question), Verses, _).
|
||||||
|
|
||||||
%% Traced version: returns verses AND the coupling trace for each
|
%% Traced version: returns verses AND the coupling trace for each.
|
||||||
|
%%
|
||||||
|
%% Over the whole scroll (2026-10-19, l1_verses.pl) a common root lights
|
||||||
|
%% hundreds of verses; a reading that cites them all cites nothing. The
|
||||||
|
%% verses are ranked by how many of the case's roots they carry — a
|
||||||
|
%% verse sharing three roots stands closer to the case than one sharing
|
||||||
|
%% one — and the reading keeps the top ?MAX_VERSES, ties broken by
|
||||||
|
%% canonical order. The coupling trace is kept only for the verses the
|
||||||
|
%% reading cites: a trace names what the reading stands on.
|
||||||
|
max_verses(12).
|
||||||
|
|
||||||
find_relevant_verses_traced(case(_Ref, _Domain, Roots, _Question), Verses, Couplings) :-
|
find_relevant_verses_traced(case(_Ref, _Domain, Roots, _Question), Verses, Couplings) :-
|
||||||
findall(verse_ref(Book, Ch, Vs, Gloss)-coupled(root(Root), verse(Book, Ch, Vs)),
|
sort(Roots, UniqueRoots),
|
||||||
( member(Root, Roots),
|
findall(Shared-verse_ref(Book, Ch, Vs, Gloss),
|
||||||
verse(Book, Ch, Vs, VerseRoots, Gloss),
|
( verse(Book, Ch, Vs, VerseRoots, Gloss),
|
||||||
|
intersection(UniqueRoots, VerseRoots, SharedRoots),
|
||||||
|
SharedRoots \== [],
|
||||||
|
length(SharedRoots, N),
|
||||||
|
Shared is -N
|
||||||
|
),
|
||||||
|
Scored),
|
||||||
|
%% keysort is stable: equal scores keep the scroll's order
|
||||||
|
keysort(Scored, Ranked),
|
||||||
|
pairs_values(Ranked, RankedVerses),
|
||||||
|
max_verses(Max),
|
||||||
|
take(Max, RankedVerses, Verses),
|
||||||
|
findall(coupled(root(Root), verse(Book, Ch, Vs)),
|
||||||
|
( member(verse_ref(Book, Ch, Vs, _), Verses),
|
||||||
|
verse(Book, Ch, Vs, VerseRoots, _),
|
||||||
|
member(Root, UniqueRoots),
|
||||||
member(Root, VerseRoots)
|
member(Root, VerseRoots)
|
||||||
),
|
),
|
||||||
AllPairs),
|
Couplings).
|
||||||
pairs_keys_values(AllPairs, AllVerses, Couplings),
|
|
||||||
sort(AllVerses, Verses).
|
take(N, List, Prefix) :-
|
||||||
|
length(List, L),
|
||||||
|
( L =< N -> Prefix = List
|
||||||
|
; length(Prefix, N), append(Prefix, _, List)
|
||||||
|
).
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% CONSTRUCT INTERPRETATION
|
%% CONSTRUCT INTERPRETATION
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -16,93 +16,19 @@
|
|||||||
]).
|
]).
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% BEREISHIT — Creation narratives
|
%% THE SCROLL — every verse of L1, generated (2026-10-19)
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
|
%%
|
||||||
|
%% The 32 hand-written verses that stood here from Phase 1 are gone:
|
||||||
|
%% with them, any root the engine knew lit the same handful and every
|
||||||
|
%% reading cited the same list. l1_verses.pl is written by
|
||||||
|
%% scripts/build_engine_verses.py from l1/torah — 5,853 verses, each
|
||||||
|
%% word's shoresh as the engine names it, the consonantal text as the
|
||||||
|
%% gloss — and carries the SHA-256 of the L1 it read. Check it against
|
||||||
|
%% the scroll with `scripts/build_engine_verses.py --check`.
|
||||||
|
|
||||||
verse(bereishit, 1, 1, [bet_resh_aleph, bet_resh_aleph, aleph_lamed_he],
|
:- include('l1_verses.pl').
|
||||||
'In the beginning God created the heavens and the earth').
|
|
||||||
verse(bereishit, 1, 3, [aleph_mem_resh, aleph_lamed_he, he_yod_he, aleph_vav_resh],
|
|
||||||
'And God said let there be light and there was light').
|
|
||||||
verse(bereishit, 1, 4, [resh_aleph_he, aleph_lamed_he, aleph_vav_resh, tet_vav_bet, bet_dalet_lamed],
|
|
||||||
'And God saw that the light was good and God separated').
|
|
||||||
verse(bereishit, 1, 27, [bet_resh_aleph, aleph_lamed_he, aleph_dalet_mem, tzade_lamed_mem, bet_resh_aleph],
|
|
||||||
'And God created man in His image in the image of God He created him').
|
|
||||||
verse(bereishit, 2, 2, [kaf_lamed_he, aleph_lamed_he, yod_vav_mem, shin_bet_tav, mem_lamed_aleph_kaf_he],
|
|
||||||
'And on the seventh day God completed His work and He rested').
|
|
||||||
verse(bereishit, 2, 3, [bet_resh_kaf, aleph_lamed_he, yod_vav_mem, shin_bet_tav, qof_dalet_shin],
|
|
||||||
'And God blessed the seventh day and sanctified it').
|
|
||||||
verse(bereishit, 9, 6, [shin_pe_kaf, dalet_mem, aleph_dalet_mem, tzade_lamed_mem, aleph_lamed_he],
|
|
||||||
'Whoever sheds the blood of man by man shall his blood be shed for in the image of God He made man').
|
|
||||||
|
|
||||||
%% ============================================================
|
|
||||||
%% SHEMOT — The ten dibrot and the covenant
|
|
||||||
%% ============================================================
|
|
||||||
|
|
||||||
verse(shemot, 3, 14, [aleph_he_yod_he, aleph_mem_resh, aleph_he_yod_he],
|
|
||||||
'I am that I am').
|
|
||||||
verse(shemot, 20, 2, [aleph_nun_kaf_yod, he_shin_mem, aleph_lamed_he],
|
|
||||||
'I am the LORD your God who brought you out of the land of Egypt').
|
|
||||||
verse(shemot, 20, 3, [he_yod_he, aleph_lamed_he, aleph_chet_resh],
|
|
||||||
'You shall have no other gods before Me').
|
|
||||||
verse(shemot, 20, 8, [zayin_kaf_resh, yod_vav_mem, shin_bet_tav, qof_dalet_shin],
|
|
||||||
'Remember the Sabbath day to sanctify it').
|
|
||||||
verse(shemot, 20, 13, [resh_tzade_chet],
|
|
||||||
'You shall not murder').
|
|
||||||
verse(shemot, 20, 14, [nun_aleph_pe],
|
|
||||||
'You shall not commit adultery').
|
|
||||||
verse(shemot, 20, 15, [gimel_nun_bet],
|
|
||||||
'You shall not steal').
|
|
||||||
verse(shemot, 20, 16, [ayin_nun_he, shin_qof_resh, ayin_dalet],
|
|
||||||
'You shall not bear false witness against your neighbor').
|
|
||||||
verse(shemot, 20, 17, [chet_mem_dalet],
|
|
||||||
'You shall not covet').
|
|
||||||
|
|
||||||
%% ============================================================
|
|
||||||
%% VAYIKRA — Holiness and offerings
|
|
||||||
%% ============================================================
|
|
||||||
|
|
||||||
verse(vayikra, 1, 3, [qof_resh_bet, ayin_lamed_he, tav_mem_yod_mem],
|
|
||||||
'If his offering is a burnt offering let him offer a male without blemish').
|
|
||||||
verse(vayikra, 4, 27, [chet_tet_aleph, nun_pe_shin, shin_gimel_gimel_he],
|
|
||||||
'If anyone of the common people sins unintentionally').
|
|
||||||
verse(vayikra, 19, 2, [qof_dalet_shin, he_yod_he, qof_dalet_shin],
|
|
||||||
'Be holy for I the LORD your God am holy').
|
|
||||||
verse(vayikra, 19, 15, [ayin_shin_he, mem_shin_pe_tet, tzade_dalet_qof],
|
|
||||||
'In righteousness shall you judge your neighbor').
|
|
||||||
verse(vayikra, 19, 18, [aleph_he_bet, resh_ayin, kaf_mem_vav],
|
|
||||||
'You shall love your neighbor as yourself').
|
|
||||||
|
|
||||||
%% ============================================================
|
|
||||||
%% BAMIDBAR — The camp and the cloud
|
|
||||||
%% ============================================================
|
|
||||||
|
|
||||||
verse(bamidbar, 9, 23, [pe_he, he_shin_mem, chet_nun_he, pe_he, he_shin_mem, nun_samekh_ayin],
|
|
||||||
'At the mouth of the LORD they camped and at the mouth of the LORD they journeyed').
|
|
||||||
verse(bamidbar, 10, 2, [ayin_shin_he, shin_nun_yod, chet_tzade_tzade_resh_he, kaf_samekh_pe],
|
|
||||||
'Make two silver trumpets for the calling of the assembly').
|
|
||||||
verse(bamidbar, 35, 11, [mem_qof_lamed_tet, ayin_yod_resh, nun_pe_shin, resh_tzade_chet, shin_gimel_gimel_he],
|
|
||||||
'You shall designate cities of refuge for one who kills a person unintentionally').
|
|
||||||
|
|
||||||
%% ============================================================
|
|
||||||
%% DEVARIM — Moses' final instruction
|
|
||||||
%% ============================================================
|
|
||||||
|
|
||||||
verse(devarim, 6, 4, [shin_mem_ayin, yod_shin_resh_aleph_lamed, he_shin_mem, aleph_lamed_he, he_shin_mem, aleph_chet_dalet],
|
|
||||||
'Hear O Israel the LORD our God the LORD is one').
|
|
||||||
verse(devarim, 6, 5, [aleph_he_bet, he_shin_mem, aleph_lamed_he, kaf_lamed, lamed_bet_bet],
|
|
||||||
'And you shall love the LORD your God with all your heart').
|
|
||||||
verse(devarim, 16, 18, [shin_pe_tet, shin_tet_resh, nun_tav_nun, shin_ayin_resh],
|
|
||||||
'Judges and officers shall you appoint in all your gates').
|
|
||||||
verse(devarim, 16, 20, [tzade_dalet_qof, tzade_dalet_qof, resh_dalet_pe],
|
|
||||||
'Justice justice shall you pursue').
|
|
||||||
verse(devarim, 21, 1, [nun_pe_lamed, chet_lamed_lamed, shin_dalet_he, yod_dalet_ayin],
|
|
||||||
'If a slain person is found in the field and it is not known who struck him').
|
|
||||||
verse(devarim, 25, 12, [nun_tzade_lamed, resh_dalet_pe],
|
|
||||||
'You shall save the pursued from the pursuer').
|
|
||||||
verse(devarim, 26, 2, [lamed_qof_chet, resh_aleph_shin_yod_tav, pe_resh_yod, aleph_dalet_mem_he],
|
|
||||||
'You shall take of the first of all the fruit of the ground').
|
|
||||||
verse(devarim, 26, 5, [aleph_resh_mem_yod, aleph_bet_dalet, aleph_bet_yod],
|
|
||||||
'A wandering Aramean was my father').
|
|
||||||
|
|
||||||
%% ============================================================
|
%% ============================================================
|
||||||
%% QUERIES
|
%% QUERIES
|
||||||
|
|||||||
@@ -112,3 +112,52 @@ test(teiku_count_works) :-
|
|||||||
integer(Count).
|
integer(Count).
|
||||||
|
|
||||||
:- end_tests(teiku_module).
|
:- end_tests(teiku_module).
|
||||||
|
|
||||||
|
%% ============================================================
|
||||||
|
%% Tablet 55 — the dialectic over the wire: interpret, challenge,
|
||||||
|
%% resolve each answer JSON a judge can vote on. Before this the
|
||||||
|
%% engine had no "challenge" action and a resolve that succeeded came
|
||||||
|
%% back UNEXPECTED_RESPONSE (resolved_accepted had no JSON clause).
|
||||||
|
%% ============================================================
|
||||||
|
|
||||||
|
:- use_module('../src/interface/api').
|
||||||
|
:- use_module('../src/interface/json_bridge').
|
||||||
|
:- use_module('../src/interface/socket_server').
|
||||||
|
|
||||||
|
:- begin_tests(dialectic_over_the_wire).
|
||||||
|
|
||||||
|
roots_line(Action, Line) :-
|
||||||
|
format(atom(Line),
|
||||||
|
'{"action":"~w","case":{"ref":"t55-~w","question":"q","context_roots":["bet-resh-aleph","aleph-lamed-he"]}}',
|
||||||
|
[Action, Action]).
|
||||||
|
|
||||||
|
test(interpret_answers_resolved_with_verses, [nondet]) :-
|
||||||
|
roots_line(interpret, L),
|
||||||
|
socket_server:process_json_line(L, R),
|
||||||
|
get_dict(status, R, "resolved"),
|
||||||
|
get_dict(interpretation, R, I),
|
||||||
|
get_dict(verses, I, Vs), Vs \== [].
|
||||||
|
|
||||||
|
test(challenge_answers_and_carries_the_ground, [nondet]) :-
|
||||||
|
roots_line(challenge, L),
|
||||||
|
socket_server:process_json_line(L, R),
|
||||||
|
get_dict(status, R, S),
|
||||||
|
memberchk(S, ["resolved", "challenged"]),
|
||||||
|
get_dict(interpretation, R, I),
|
||||||
|
get_dict(verses, I, Vs), Vs \== [].
|
||||||
|
|
||||||
|
test(resolve_answers_resolved_not_unexpected, [nondet]) :-
|
||||||
|
roots_line(resolve, L),
|
||||||
|
socket_server:process_json_line(L, R),
|
||||||
|
\+ get_dict(error, R, _),
|
||||||
|
get_dict(status, R, S),
|
||||||
|
memberchk(S, ["resolved", "challenged", "teiku"]).
|
||||||
|
|
||||||
|
test(no_roots_resolves_on_an_empty_trace, [nondet]) :-
|
||||||
|
socket_server:process_json_line('{"action":"interpret","case":{"ref":"t55-empty","question":"q","context_roots":[]}}', R),
|
||||||
|
get_dict(status, R, "resolved"),
|
||||||
|
get_dict(interpretation, R, I),
|
||||||
|
get_dict(verses, I, []),
|
||||||
|
get_dict(grounding, I, []).
|
||||||
|
|
||||||
|
:- end_tests(dialectic_over_the_wire).
|
||||||
@@ -26,6 +26,11 @@ defmodule VavPipeline.Application do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
|
# Tablet 55: the Sanhedrin's judges consult the specification through
|
||||||
|
# this callback (the Talmudic Engine behind it). Registered before the
|
||||||
|
# supervision tree so the first panel convened can already consult.
|
||||||
|
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
|
||||||
|
|
||||||
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
|
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
|
||||||
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
|
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
|
||||||
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock")
|
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock")
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias VavPipeline.{Specification, CycleLog, Manna, HaltConditions,
|
alias VavPipeline.{Specification, CycleLog, Manna, HaltConditions,
|
||||||
ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient,
|
ShoreshEngineClient, TalmudicEngineClient, TalmudicEngineSocket,
|
||||||
Chatzotzrot}
|
SanhedrinClient, Chatzotzrot}
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@@ -308,7 +308,11 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
final =
|
final =
|
||||||
case result do
|
case result do
|
||||||
{:ok, data} ->
|
{:ok, data} ->
|
||||||
check_halt_on_output(data)
|
# Every question is read against the engine, not only a teiku
|
||||||
|
# that never comes (2026-10-19: ki_tov returns teiku only on a
|
||||||
|
# root with no Hebrew, and the Shoresh Engine never leaves one;
|
||||||
|
# the Talmudic route had never been walked by a real question).
|
||||||
|
data |> consult_engine(context) |> check_halt_on_output()
|
||||||
|
|
||||||
{:unresolved, data} ->
|
{:unresolved, data} ->
|
||||||
case route_if_unresolved(data, context) do
|
case route_if_unresolved(data, context) do
|
||||||
@@ -325,6 +329,90 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
final
|
final
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# The engine, consulted on every resolved cycle
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Architecture §3.3: the Talmudic Engine grounds a reading in verses
|
||||||
|
# and mitzvot over the case's roots. The cycle's own ki_tov says the
|
||||||
|
# roots stand in L1; the engine says what stands on them. Called
|
||||||
|
# directly on the socket — never the client's stub (tablet 41): an
|
||||||
|
# engine that cannot be reached is reported as not consulted, and the
|
||||||
|
# cycle's answer stands without it. A tamid probe does not consult
|
||||||
|
# (the fire is checked, the engine is not loaded by the check).
|
||||||
|
defp consult_engine(%{probe: true} = data, _context), do: data
|
||||||
|
|
||||||
|
defp consult_engine(data, _context) do
|
||||||
|
roots = Map.get(data, :roots_activated, [])
|
||||||
|
names = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
|
||||||
|
case_ref = Map.get(data, :case_ref, generate_case_ref())
|
||||||
|
|
||||||
|
cond do
|
||||||
|
names == [] ->
|
||||||
|
Map.put(data, :talmudic_consulted, {:skipped, :no_roots})
|
||||||
|
|
||||||
|
Process.whereis(TalmudicEngineSocket) == nil ->
|
||||||
|
Map.put(data, :talmudic_consulted, {:error, :engine_socket_not_started})
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case TalmudicEngineSocket.interpret(case_ref, %{context_roots: names, question: Map.get(data, :raw, "")}) do
|
||||||
|
{:ok, %{"status" => _} = reply} ->
|
||||||
|
Map.merge(data, %{talmudic_interpretation: reply, talmudic_consulted: :ok})
|
||||||
|
|
||||||
|
{:ok, other} ->
|
||||||
|
Logger.warning("BereishitCycle: engine answered an unexpected shape for #{case_ref}: #{inspect(other)}")
|
||||||
|
Map.put(data, :talmudic_consulted, {:error, :unexpected_reply})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("BereishitCycle: engine not consulted for #{case_ref}: #{inspect(reason)}")
|
||||||
|
Map.put(data, :talmudic_consulted, {:error, reason})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The engine's reading, said plainly: how many verses and mitzvot it
|
||||||
|
coupled to the roots, and which. `nil` when there is no reading.
|
||||||
|
"""
|
||||||
|
@spec engine_reading_text(map()) :: String.t() | nil
|
||||||
|
def engine_reading_text(data) do
|
||||||
|
case Map.get(data, :talmudic_interpretation) do
|
||||||
|
%{"interpretation" => interp} = reply when is_map(interp) ->
|
||||||
|
verses = interp |> Map.get("verses", []) |> List.wrap() |> Enum.map(&verse_ref_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
|
||||||
|
mitzvot = interp |> Map.get("grounding", []) |> List.wrap() |> Enum.map(&mitzvah_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
|
||||||
|
status = Map.get(reply, "status", "")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
verses == [] and mitzvot == [] ->
|
||||||
|
"Talmudic Engine (#{status}): no verse or mitzvah is coupled to these roots."
|
||||||
|
|
||||||
|
true ->
|
||||||
|
v = if verses == [], do: "no verse", else: "#{length(verses)} verse(s): #{Enum.join(verses, ", ")}"
|
||||||
|
m = if mitzvot == [], do: "no mitzvah", else: "#{length(mitzvot)} mitzvah/mitzvot: #{Enum.join(mitzvot, ", ")}"
|
||||||
|
"Talmudic Engine (#{status}): #{v}; #{m}."
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# "verse_ref(bereishit,1,1,'...')" / "verse(shemot,3,14)" → "Bereishit 1:1"
|
||||||
|
defp verse_ref_display(s) do
|
||||||
|
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
|
||||||
|
[_, book, ch, vs] -> "#{String.capitalize(book)} #{ch}:#{vs}"
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# "mitzvah(1,positive,know_hashem,always)" → "know_hashem (1, positive)"
|
||||||
|
defp mitzvah_display(s) do
|
||||||
|
case Regex.run(~r/mitzvah\((\d+)\s*,\s*(\w+)\s*,\s*(\w+)/, to_string(s)) do
|
||||||
|
[_, id, kind, name] -> "#{name} (#{id}, #{kind})"
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Compose — the answer and its sources, from the context
|
# Compose — the answer and its sources, from the context
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -385,15 +473,19 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
deliberation =
|
deliberation =
|
||||||
case Map.get(data, :resolution) do
|
case Map.get(data, :resolution) do
|
||||||
:talmudic ->
|
:talmudic ->
|
||||||
text = get_in(data, [:talmudic_interpretation, "interpretation", "text"]) || ""
|
" " <> (engine_reading_text(data) || "Talmudic Engine resolved the case.")
|
||||||
" Talmudic Engine: #{text}"
|
|
||||||
|
|
||||||
:sanhedrin ->
|
:sanhedrin ->
|
||||||
pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || ""
|
pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || ""
|
||||||
" Sanhedrin majority: #{pos}"
|
" Sanhedrin majority: #{pos}"
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
""
|
# Consulted after ki_tov (every resolved question), or not.
|
||||||
|
case {engine_reading_text(data), Map.get(data, :talmudic_consulted)} do
|
||||||
|
{text, _} when is_binary(text) -> " " <> text
|
||||||
|
{nil, {:error, reason}} -> " Talmudic Engine: not consulted (#{inspect(reason)})."
|
||||||
|
_ -> ""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
"Roots activated in #{verse.ref}: #{Enum.join(root_lines, "; ")}." <>
|
"Roots activated in #{verse.ref}: #{Enum.join(root_lines, "; ")}." <>
|
||||||
@@ -426,10 +518,19 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
text: verse.text_hebrew
|
text: verse.text_hebrew
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The engine's verses as references a reader can follow ("Bereishit
|
||||||
|
# 1:3"), the engine's own form kept beside them; the scope verse is
|
||||||
|
# not repeated.
|
||||||
talmudic_verses =
|
talmudic_verses =
|
||||||
case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do
|
case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do
|
||||||
vs when is_list(vs) -> Enum.map(vs, fn v -> %{ref: to_string(v)} end)
|
vs when is_list(vs) ->
|
||||||
_ -> []
|
vs
|
||||||
|
|> Enum.map(fn v -> %{ref: verse_ref_display(v) || to_string(v), engine_form: to_string(v)} end)
|
||||||
|
|> Enum.uniq_by(& &1.ref)
|
||||||
|
|> Enum.reject(&(&1.ref == verse.ref))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
[primary | talmudic_verses]
|
[primary | talmudic_verses]
|
||||||
@@ -604,13 +705,22 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
{:interpretation, attrs, nil}
|
{:interpretation, attrs, nil}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
# The engine's reading, when it was consulted, rides in the record:
|
||||||
|
# its verses beside the scope, its text in the reasoning, and the
|
||||||
|
# component names both readers.
|
||||||
|
interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{}
|
||||||
|
engine_verses = interp |> Map.get("verses", []) |> to_string_list()
|
||||||
|
reading = engine_reading_text(data)
|
||||||
|
|
||||||
attrs =
|
attrs =
|
||||||
Map.merge(base, %{
|
Map.merge(base, %{
|
||||||
reasoning: single_judge_reasoning(data, context),
|
reasoning:
|
||||||
|
if(reading, do: single_judge_reasoning(data, context) <> " " <> reading, else: single_judge_reasoning(data, context)),
|
||||||
|
torah_references: Enum.uniq([scope_ref | engine_verses]),
|
||||||
panel_size: 1,
|
panel_size: 1,
|
||||||
vote_count_for: 1,
|
vote_count_for: 1,
|
||||||
vote_count_against: 0,
|
vote_count_against: 0,
|
||||||
processing_component: "bereishit_cycle"
|
processing_component: if(reading, do: "bereishit_cycle+talmudic_engine", else: "bereishit_cycle")
|
||||||
})
|
})
|
||||||
|
|
||||||
{:interpretation, attrs, nil}
|
{:interpretation, attrs, nil}
|
||||||
@@ -740,7 +850,10 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
@doc false
|
@doc false
|
||||||
defp route_if_unresolved(data, context) do
|
defp route_if_unresolved(data, context) do
|
||||||
roots = Map.get(data, :roots_activated, [])
|
roots = Map.get(data, :roots_activated, [])
|
||||||
context_roots = Enum.map(roots, & &1.shoresh)
|
# Tablet 55: the engine names roots by letter (bet_resh_aleph); the
|
||||||
|
# Hebrew was sent since Step 14 and never matched a verse fact, so
|
||||||
|
# every Talmudic interpretation was "resolved" over an empty trace.
|
||||||
|
context_roots = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
case_ref = Map.get(data, :case_ref, generate_case_ref())
|
case_ref = Map.get(data, :case_ref, generate_case_ref())
|
||||||
|
|
||||||
@@ -970,6 +1083,40 @@ defmodule VavPipeline.BereishitCycle do
|
|||||||
|
|
||||||
defp letters_to_hebrew(_), do: nil
|
defp letters_to_hebrew(_), do: nil
|
||||||
|
|
||||||
|
# The inverse: Hebrew consonant → letter name, finals folded to their base.
|
||||||
|
@hebrew_to_name Map.new(@consonant_names, fn {name, heb} -> {heb, name} end)
|
||||||
|
|> Map.merge(%{"ך" => "kaf", "ם" => "mem", "ן" => "nun", "ף" => "pe", "ץ" => "tsadi"})
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The root as the Talmudic Engine names it — letter names joined by `-`
|
||||||
|
(`json_bridge` `parse_root_name` → `bet_resh_aleph`, the form of its
|
||||||
|
verse facts). From the letter names the Shoresh Engine gives when it
|
||||||
|
has them, else from the Hebrew (the L1 fallback carries only that).
|
||||||
|
Tablet 55: the engine was being sent the Hebrew, which its facts never
|
||||||
|
match — 5 roots in, 0 verses out, and "resolved" over the emptiness.
|
||||||
|
`nil` when the root carries nothing nameable.
|
||||||
|
"""
|
||||||
|
@spec engine_root_name(map()) :: String.t() | nil
|
||||||
|
def engine_root_name(root) when is_map(root) do
|
||||||
|
letters = Map.get(root, :shoresh_letters) || []
|
||||||
|
|
||||||
|
names =
|
||||||
|
if is_list(letters) and letters != [] do
|
||||||
|
Enum.map(letters, &String.downcase(to_string(&1)))
|
||||||
|
else
|
||||||
|
root
|
||||||
|
|> Map.get(:shoresh)
|
||||||
|
|> case do
|
||||||
|
s when is_binary(s) -> s |> String.graphemes() |> Enum.map(&Map.get(@hebrew_to_name, &1))
|
||||||
|
_ -> [nil]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if names != [] and Enum.all?(names, &is_binary/1), do: Enum.join(names, "-"), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def engine_root_name(_), do: nil
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Private helpers
|
# Private helpers
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -1084,32 +1084,319 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
resubmit: true
|
resubmit: true
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
case_ref = generate_id()
|
# Tablet 55: the sides are the caller's arguments, not the halt
|
||||||
|
# gate's context. The gate reads a MAP (authority_source, action);
|
||||||
|
# handing it the sides list crashed the halt monitor and escalation
|
||||||
|
# with the Tamid's own example request. The sides ride inside the
|
||||||
|
# map, their shape checked at the door; and a refusal from the
|
||||||
|
# Sanhedrin is answered, not matched into a 500.
|
||||||
|
#
|
||||||
|
# And the case is RESOLVED before it is submitted — Construction
|
||||||
|
# §3.2: the pipeline hands the Sanhedrin roots and torah_refs. The
|
||||||
|
# question is heard the way pe'ah hears it (scope → verse → Shoresh
|
||||||
|
# Engine → roots); the complexity hint sets the panel; a panel
|
||||||
|
# spends one unit of the day's panel manna; the decision is
|
||||||
|
# engraved in L3; the answer takes the Tamid's shape.
|
||||||
|
with {:ok, sides} <- sanhedrin_sides(params["sides"]),
|
||||||
|
{:ok, complexity} <- sanhedrin_complexity(params["complexity_hint"]),
|
||||||
|
{:ok, scope, resolved_by} <- sanhedrin_scope(case_text),
|
||||||
|
{:ok, resolved} <- sanhedrin_resolve(case_text, scope, resolved_by),
|
||||||
|
:ok <- sanhedrin_gather_panel() do
|
||||||
|
case_ref = generate_id()
|
||||||
|
|
||||||
{:ok, decision} =
|
case VavPipeline.SanhedrinClient.submit_case(%{
|
||||||
VavPipeline.SanhedrinClient.submit_case(%{
|
case_ref: case_ref,
|
||||||
case_ref: case_ref,
|
complexity: complexity,
|
||||||
complexity: :simple,
|
question: case_text,
|
||||||
question: case_text,
|
context: %{
|
||||||
context: params["sides"] || %{},
|
sides: sides,
|
||||||
requested_by: "chatzer"
|
roots: resolved.roots,
|
||||||
})
|
torah_refs: [resolved.torah_ref],
|
||||||
|
authority_source: :torah
|
||||||
|
},
|
||||||
|
requested_by: "chatzer"
|
||||||
|
}) do
|
||||||
|
{:ok, decision} ->
|
||||||
|
recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved)
|
||||||
|
|
||||||
send_json(conn, 200, %{
|
send_json(conn, 200, %{
|
||||||
case_ref: decision.case_ref,
|
case_ref: decision.case_ref,
|
||||||
deliberation: %{
|
deliberation: %{
|
||||||
panel_size: decision.panel_size,
|
panel_size: decision.panel_size,
|
||||||
majority: decision.majority,
|
majority: sanhedrin_side(decision.majority),
|
||||||
minority: decision.minority,
|
minority: sanhedrin_side(decision.minority),
|
||||||
outcome: decision.outcome,
|
outcome: decision.outcome,
|
||||||
processing_time_ms: decision.deliberation_time_ms
|
processing_time_ms: decision.deliberation_time_ms
|
||||||
},
|
},
|
||||||
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
|
grounding: %{
|
||||||
disclosure: disclosure()
|
scope: scope_report(scope, resolved_by),
|
||||||
})
|
roots_consulted: format_roots(resolved.roots_full),
|
||||||
|
recorded: recorded
|
||||||
|
},
|
||||||
|
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
|
||||||
|
disclosure: disclosure()
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, "Halted" <> _ = reason} ->
|
||||||
|
send_error(conn, 422, "SANHEDRIN_HALTED", %{
|
||||||
|
issue: reason,
|
||||||
|
correction: "The case was refused at the gate before deliberation. Amend it and resubmit.",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
send_error(conn, 503, "SANHEDRIN_UNAVAILABLE", %{
|
||||||
|
issue: reason,
|
||||||
|
correction: "The Sanhedrin did not deliberate. Retry; if it recurs, the kohanim will find it in the log.",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :sides, issue} ->
|
||||||
|
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||||||
|
issue: issue,
|
||||||
|
correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, :complexity, issue} ->
|
||||||
|
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||||||
|
issue: issue,
|
||||||
|
correction: "'complexity_hint' is one of simple | monetary | capital | novel (panels of 1 | 3 | 23 | 71)",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, {:invalid_reference, ref}} ->
|
||||||
|
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||||||
|
issue: "INVALID_SCOPE: #{ref} is not a verse in L1",
|
||||||
|
correction: "Name a verse that exists — chapter and verse within the five books.",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:waiting, reason} when reason in [:no_provision, :provision_exhausted] ->
|
||||||
|
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||||||
|
issue: "No panel can be convened today: sanhedrin_panels_max is #{reason}",
|
||||||
|
correction: "Retry tomorrow, or raise MANNA_SANHEDRIN_PANELS_MAX in the ConfigMap",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:halted, what, reason} ->
|
||||||
|
send_error(conn, 503, "HALT_#{String.upcase(to_string(what))}", %{
|
||||||
|
issue: "The case could not be heard: #{inspect(reason)}",
|
||||||
|
correction: "Retry when the halt clears",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
|
||||||
|
{:transition, signal} ->
|
||||||
|
send_error(conn, 503, "CLOUD_MOVING", %{
|
||||||
|
issue: "The cloud is in transition (#{signal})",
|
||||||
|
correction: "Retry when the cloud settles",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The optional sides: absent → []; a list of maps each with a string
|
||||||
|
# "position" and an optional list of string "evidence" → kept as given;
|
||||||
|
# anything else → refused at the kiyor.
|
||||||
|
defp sanhedrin_sides(nil), do: {:ok, []}
|
||||||
|
|
||||||
|
defp sanhedrin_sides(sides) when is_list(sides) do
|
||||||
|
if Enum.all?(sides, &side_ok?/1),
|
||||||
|
do: {:ok, sides},
|
||||||
|
else: {:error, :sides, "'sides' must be a list of {position, evidence[]} objects"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanhedrin_sides(_), do: {:error, :sides, "'sides' must be a list"}
|
||||||
|
|
||||||
|
# The complexity hint names the panel (sanhedrin.hrl): absent → simple.
|
||||||
|
@sanhedrin_complexities %{"simple" => :simple, "monetary" => :monetary, "capital" => :capital, "novel" => :novel}
|
||||||
|
defp sanhedrin_complexity(nil), do: {:ok, :simple}
|
||||||
|
|
||||||
|
defp sanhedrin_complexity(hint) when is_binary(hint) do
|
||||||
|
case Map.fetch(@sanhedrin_complexities, hint) do
|
||||||
|
{:ok, c} -> {:ok, c}
|
||||||
|
:error -> {:error, :complexity, "'complexity_hint' #{inspect(hint)} is not one of simple | monetary | capital | novel"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanhedrin_complexity(other), do: {:error, :complexity, "'complexity_hint' must be a string, got #{inspect(other)}"}
|
||||||
|
|
||||||
|
# The scope: heard from the question (tablet 30), a named verse or a text
|
||||||
|
# match, else the default.
|
||||||
|
defp sanhedrin_scope(case_text), do: VavPipeline.ScopeResolver.resolve(case_text)
|
||||||
|
|
||||||
|
# The roots: the first two stages of the Bereishit cycle on the question —
|
||||||
|
# tohu (decompose) and vayomer (the Shoresh Engine over the verse in scope,
|
||||||
|
# or the question's own Hebrew). Nishma first: the Shema context is
|
||||||
|
# received, and the cloud is honoured, exactly as process/1 does.
|
||||||
|
defp sanhedrin_resolve(case_text, scope, resolved_by) do
|
||||||
|
case VavPipeline.Shema.receive_context(scope) do
|
||||||
|
{:error, reason} ->
|
||||||
|
{:halted, :context_failed, reason}
|
||||||
|
|
||||||
|
{:ok, context} ->
|
||||||
|
case context.cloud_state do
|
||||||
|
:camped ->
|
||||||
|
input = %{source: "chatzer", verse_scope: scope, scope_resolution: resolved_by, content: case_text}
|
||||||
|
|
||||||
|
state =
|
||||||
|
input
|
||||||
|
|> VavPipeline.BereishitCycle.tohu(context)
|
||||||
|
|> VavPipeline.BereishitCycle.vayomer(context)
|
||||||
|
|
||||||
|
roots_full = Map.get(state, :roots_activated, [])
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
# The engine's verse facts name roots by letter — bet_resh_aleph —
|
||||||
|
# and json_bridge reads "bet-resh-aleph". The cycle's root carries
|
||||||
|
# the letter names beside the Hebrew; the Hebrew never matches
|
||||||
|
# (live, 2026-10-19: 5 roots sent, 0 verses found, "resolved").
|
||||||
|
roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1),
|
||||||
|
roots_full: roots_full,
|
||||||
|
torah_ref: {String.to_atom(scope.book), scope.chapter, scope.verse},
|
||||||
|
read_from: Map.get(state, :roots_read_from)
|
||||||
|
}}
|
||||||
|
|
||||||
|
:moving ->
|
||||||
|
{:transition, :cloud_lifted}
|
||||||
|
|
||||||
|
{:major_transition, signal, _} ->
|
||||||
|
{:transition, signal}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
{:transition, other}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# One panel per case from the day's manna (Vayakhel §3.7 — the ledger
|
||||||
|
# provisions sanhedrin_panels_max). The provision is loaded first, as
|
||||||
|
# Gate.gather_call does (idempotent per date). Without a ledger
|
||||||
|
# (dev/test) nothing is gathered.
|
||||||
|
defp sanhedrin_gather_panel do
|
||||||
|
if Process.whereis(VavPipeline.MannaLedger) do
|
||||||
|
case VavPipeline.MannaLedger.load_provision() do
|
||||||
|
{:ok, :no_provision} ->
|
||||||
|
{:waiting, :no_provision}
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
{:waiting, :no_provision}
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
case VavPipeline.MannaLedger.consume(:sanhedrin_panels_max, 1) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, :provision_exhausted} -> {:waiting, :provision_exhausted}
|
||||||
|
{:error, :no_provision} -> {:waiting, :no_provision}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The Tamid's shape for a side: position, reasoning, sources, votes.
|
||||||
|
defp sanhedrin_side(side) when is_map(side) do
|
||||||
|
%{
|
||||||
|
position: Map.get(side, :position, Map.get(side, "position", "")),
|
||||||
|
reasoning: Map.get(side, :reasoning, Map.get(side, "reasoning", "")),
|
||||||
|
sources: side |> Map.get(:torah_refs, Map.get(side, "torah_refs", [])) |> Enum.map(&sanhedrin_ref_string/1),
|
||||||
|
votes: Map.get(side, :vote_count, Map.get(side, "vote_count", 0))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanhedrin_side(_), do: %{position: "", reasoning: "", sources: [], votes: 0}
|
||||||
|
|
||||||
|
# #torah_ref{} arrives from Erlang as {:torah_ref, book, ch, vs}; a tuple
|
||||||
|
# {book, ch, vs} or a map may also appear.
|
||||||
|
defp sanhedrin_ref_string({:torah_ref, book, ch, vs}), do: "#{book} #{ch}:#{vs}"
|
||||||
|
defp sanhedrin_ref_string({book, ch, vs}), do: "#{book} #{ch}:#{vs}"
|
||||||
|
defp sanhedrin_ref_string(%{book: b, chapter: c, verse: v}), do: "#{b} #{c}:#{v}"
|
||||||
|
defp sanhedrin_ref_string(other), do: to_string(inspect(other))
|
||||||
|
|
||||||
|
# Devarim 17:9 — the inquiry is recorded or it did not accumulate. The
|
||||||
|
# decision goes to L3 as an interpretation (panel_size, votes for and
|
||||||
|
# against, processing_component "sanhedrin"), the minority beside it
|
||||||
|
# when it exists. Never turns a served decision into a 500: the record
|
||||||
|
# failing is logged and reported in the reply as `recorded`.
|
||||||
|
defp sanhedrin_record_l3(case_ref, case_text, decision, resolved) do
|
||||||
|
if Process.whereis(VavPipeline.Repo) do
|
||||||
|
maj = decision.majority
|
||||||
|
min = decision.minority
|
||||||
|
{book, ch, vs} = resolved.torah_ref
|
||||||
|
verse_s = "#{book} #{ch}:#{vs}"
|
||||||
|
|
||||||
|
coupling =
|
||||||
|
case resolved.roots do
|
||||||
|
[] -> ["coupled(case(#{case_ref}), verse(#{book}, #{ch}, #{vs}))"]
|
||||||
|
roots -> Enum.map(roots, fn r -> "coupled(root(#{r}), verse(#{book}, #{ch}, #{vs}))" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
case_ref: case_ref,
|
||||||
|
interpretation: to_string(Map.get(maj, :position, "")),
|
||||||
|
status: to_string(decision.outcome),
|
||||||
|
reasoning: "#{case_text} — #{Map.get(maj, :reasoning, "")}",
|
||||||
|
torah_references: Enum.uniq([verse_s | Enum.map(Map.get(maj, :torah_refs, []), &sanhedrin_ref_string/1)]),
|
||||||
|
roots_involved: Enum.map(resolved.roots, &to_string/1),
|
||||||
|
panel_size: decision.panel_size,
|
||||||
|
vote_count_for: Map.get(maj, :vote_count, 0),
|
||||||
|
vote_count_against: Map.get(min, :vote_count, 0),
|
||||||
|
processing_component: "sanhedrin",
|
||||||
|
coupling_trace: coupling
|
||||||
|
}
|
||||||
|
|
||||||
|
try do
|
||||||
|
case VavPipeline.L3.Store.record_interpretation(attrs) do
|
||||||
|
{:ok, interp} ->
|
||||||
|
Logger.info("L3: interpretation #{interp.id} recorded for sanhedrin #{case_ref} (#{decision.outcome})")
|
||||||
|
|
||||||
|
minority_recorded =
|
||||||
|
if Map.get(min, :vote_count, 0) > 0 do
|
||||||
|
case VavPipeline.L3.Store.record_minority(%{
|
||||||
|
interpretation_id: interp.id,
|
||||||
|
dissenting_reasoning: "#{Map.get(min, :position, "")} — #{Map.get(min, :reasoning, "")}",
|
||||||
|
torah_references: Enum.map(Map.get(min, :torah_refs, []), &sanhedrin_ref_string/1),
|
||||||
|
grounding_type: "achorai"
|
||||||
|
}) do
|
||||||
|
{:ok, _} ->
|
||||||
|
"recorded"
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("L3: sanhedrin #{case_ref} minority NOT recorded: #{inspect(reason)}")
|
||||||
|
"failed"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
"none"
|
||||||
|
end
|
||||||
|
|
||||||
|
%{l3: "recorded", interpretation_id: interp.id, minority: minority_recorded}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{inspect(reason)}")
|
||||||
|
%{l3: "failed", reason: inspect(reason)}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{Exception.message(e)}")
|
||||||
|
%{l3: "failed", reason: Exception.message(e)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
%{l3: "absent"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp side_ok?(%{"position" => p} = side) when is_binary(p) do
|
||||||
|
case Map.get(side, "evidence", []) do
|
||||||
|
ev when is_list(ev) -> Enum.all?(ev, &is_binary/1)
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp side_ok?(_), do: false
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Internal — Health and Status
|
# Internal — Health and Status
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
defmodule VavPipeline.SanhedrinConsult do
|
||||||
|
@moduledoc """
|
||||||
|
What a judge consults (Architecture §3.2: "consults the specification
|
||||||
|
(L0/L1)"). Registered in the `sanhedrin` application env as
|
||||||
|
`{__MODULE__, :consult}` at pipeline start; `judge.erl` calls it once
|
||||||
|
per vote with the step its seat performs — `:interpret`, `:challenge`
|
||||||
|
or `:resolve` — and the case's roots and question.
|
||||||
|
|
||||||
|
Tablet 55: before this the judge chose among six canned positions by a
|
||||||
|
hash of the clock. Now the only ground a vote can stand on is the
|
||||||
|
Talmudic Engine's answer over the case's roots — the same engine, the
|
||||||
|
same rules, the same verses the bedikah and the Bereishit cycle use.
|
||||||
|
When the engine's socket is not running, or answers with an error, the
|
||||||
|
judge is told `{:error, reason}` and abstains. Nothing here is answered
|
||||||
|
from a stub (tablet 41): `TalmudicEngineClient`'s stub fallbacks are
|
||||||
|
deliberately NOT used — a court that accepts on a stub is the calf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias VavPipeline.TalmudicEngineSocket
|
||||||
|
|
||||||
|
@books ~w(bereishit shemot vayikra bamidbar devarim)a
|
||||||
|
|
||||||
|
@typedoc "What the judge receives: a status the engine gave, the reading, its verses."
|
||||||
|
@type consultation :: %{
|
||||||
|
required(:status) => String.t(),
|
||||||
|
required(:text) => String.t(),
|
||||||
|
required(:verses) => [{atom(), pos_integer(), pos_integer()}],
|
||||||
|
optional(:kushya) => String.t(),
|
||||||
|
optional(:grounding) => [String.t()]
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Called from the judge (Erlang): `consult(Step, CaseRef, Roots, Question, Context)`.
|
||||||
|
Roots are the case's shoresh names as the cycle produces them; the
|
||||||
|
Erlang side hands binaries or charlists — both are accepted.
|
||||||
|
"""
|
||||||
|
@spec consult(:interpret | :challenge | :resolve, binary() | charlist(), list(), binary() | charlist(), map()) ::
|
||||||
|
{:ok, consultation()} | {:error, term()}
|
||||||
|
def consult(step, case_ref, roots, question, _context) when step in [:interpret, :challenge, :resolve] do
|
||||||
|
if Process.whereis(TalmudicEngineSocket) do
|
||||||
|
ctx = %{
|
||||||
|
context_roots: Enum.map(roots, &to_string/1),
|
||||||
|
question: to_string(question)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref = "#{to_string(case_ref)}-#{step}"
|
||||||
|
|
||||||
|
case apply(TalmudicEngineSocket, step, [ref, ctx]) do
|
||||||
|
{:ok, %{"status" => status} = reply} when is_binary(status) ->
|
||||||
|
{:ok, normalize(status, reply)}
|
||||||
|
|
||||||
|
{:ok, other} ->
|
||||||
|
{:error, {:unexpected_reply, other}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("SanhedrinConsult: engine #{step} failed for #{ref}: #{inspect(reason)}")
|
||||||
|
{:error, {:engine_unavailable, reason}}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :engine_socket_not_started}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def consult(step, _ref, _roots, _q, _ctx), do: {:error, {:unknown_step, step}}
|
||||||
|
|
||||||
|
@doc "Parse the engine's verse strings (`verse_ref(bereishit,1,1,...)`, `verse(shemot,3,14)`) into `{book, ch, vs}`."
|
||||||
|
@spec verses_from([String.t()]) :: [{atom(), pos_integer(), pos_integer()}]
|
||||||
|
def verses_from(strings) when is_list(strings) do
|
||||||
|
strings
|
||||||
|
|> Enum.flat_map(fn s ->
|
||||||
|
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
|
||||||
|
[_, book, ch, vs] -> [{String.to_existing_atom(book), String.to_integer(ch), String.to_integer(vs)}]
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
def verses_from(_), do: []
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def books, do: @books
|
||||||
|
|
||||||
|
defp normalize(status, reply) do
|
||||||
|
interp = Map.get(reply, "interpretation") || %{}
|
||||||
|
chall = Map.get(reply, "challenge") || %{}
|
||||||
|
|
||||||
|
base = %{
|
||||||
|
status: status,
|
||||||
|
text: to_string(Map.get(interp, "text", "")),
|
||||||
|
verses: verses_from(List.wrap(Map.get(interp, "verses", []))),
|
||||||
|
grounding: List.wrap(Map.get(interp, "grounding", [])) |> Enum.map(&to_string/1)
|
||||||
|
}
|
||||||
|
|
||||||
|
case Map.get(chall, "kushya") do
|
||||||
|
k when is_binary(k) and k != "" -> Map.put(base, :kushya, k)
|
||||||
|
_ -> base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -21,6 +21,38 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
|
|
||||||
@opts Router.init([])
|
@opts Router.init([])
|
||||||
|
|
||||||
|
# Tablet 55: a consultation the judges can be pointed at (judge.erl reads
|
||||||
|
# {M, F} from the sanhedrin env). Seat 2 (challenge) finds a kushya; the
|
||||||
|
# other seats read the verse as resolved.
|
||||||
|
def fake_consult(:challenge, _ref, _roots, _q, _ctx),
|
||||||
|
do: {:ok, %{status: "challenged", text: "", kushya: "but the verse says otherwise", verses: [{:devarim, 6, 4}]}}
|
||||||
|
|
||||||
|
def fake_consult(_step, _ref, _roots, _q, _ctx),
|
||||||
|
do: {:ok, %{status: "resolved", text: "the reading", verses: [{:bereishit, 1, 1}]}}
|
||||||
|
|
||||||
|
# A day's manna for the chatzer tests: a panel spends one unit of
|
||||||
|
# sanhedrin_panels_max (tablet 55), so the ledger must hold a provision.
|
||||||
|
defp write_provision(panels) do
|
||||||
|
VavPipeline.MannaLedger.reset()
|
||||||
|
on_exit(fn -> VavPipeline.MannaLedger.reset() end)
|
||||||
|
today = Date.utc_today() |> Date.to_iso8601()
|
||||||
|
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
|
||||||
|
|
||||||
|
provision = %{
|
||||||
|
"date" => today,
|
||||||
|
"provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => panels},
|
||||||
|
"type" => "regular",
|
||||||
|
"expires_at" => expires,
|
||||||
|
"written_by" => "bigdei_kodesh_test",
|
||||||
|
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
|
||||||
|
}
|
||||||
|
|
||||||
|
path = VavPipeline.Manna.provision_file()
|
||||||
|
File.mkdir_p!(Path.dirname(path))
|
||||||
|
File.write!(path, Jason.encode!(provision))
|
||||||
|
on_exit(fn -> File.rm(path) end)
|
||||||
|
end
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Pe'ah — Public Endpoint (Machaneh Yisrael)
|
# Pe'ah — Public Endpoint (Machaneh Yisrael)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -301,6 +333,8 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "POST /chatzer/sanhedrin with valid case deliberates" do
|
test "POST /chatzer/sanhedrin with valid case deliberates" do
|
||||||
|
write_provision(2)
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
|
||||||
case: "Is cooking on Shabbat permitted for pikuach nefesh?"
|
case: "Is cooking on Shabbat permitted for pikuach nefesh?"
|
||||||
@@ -317,6 +351,170 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
||||||
assert body["note"] =~ "not a binding ruling"
|
assert body["note"] =~ "not a binding ruling"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Tablet 55: the Tamid's own example — a case WITH sides — returned
|
||||||
|
# 500 and took the halt monitor and escalation down with it.
|
||||||
|
test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do
|
||||||
|
write_provision(2)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
|
||||||
|
case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?",
|
||||||
|
sides: [
|
||||||
|
%{position: "the borrower is believed", evidence: ["the money was handed over before a witness"]},
|
||||||
|
%{position: "the lender is believed", evidence: ["one witness does not establish payment"]}
|
||||||
|
],
|
||||||
|
complexity_hint: "monetary"
|
||||||
|
}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = Jason.decode!(conn.resp_body)
|
||||||
|
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
||||||
|
# the hint names the panel: monetary → three judges (sanhedrin.hrl)
|
||||||
|
assert body["deliberation"]["panel_size"] == 3
|
||||||
|
# the Tamid's shape — votes and sources, not vote_count and torah_refs
|
||||||
|
assert Map.keys(body["deliberation"]["majority"]) |> Enum.sort() == ["position", "reasoning", "sources", "votes"]
|
||||||
|
assert is_list(body["deliberation"]["majority"]["sources"])
|
||||||
|
# the case was resolved before it was heard
|
||||||
|
assert is_map(body["grounding"]["scope"])
|
||||||
|
assert is_list(body["grounding"]["roots_consulted"])
|
||||||
|
# and the gate is still standing afterwards
|
||||||
|
assert Process.whereis(:halt_monitor) != nil
|
||||||
|
assert Process.whereis(:escalation) != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tablet 55: without a Talmudic Engine to consult, the judges abstain
|
||||||
|
# and the court says teiku — it does not accept on nothing.
|
||||||
|
test "POST /chatzer/sanhedrin with no engine to consult is teiku, not accepted" do
|
||||||
|
# The socket process may run in the test env; no engine answers behind it.
|
||||||
|
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
|
||||||
|
write_provision(2)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "bereishit 1:1 — what is created first?"}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = Jason.decode!(conn.resp_body)
|
||||||
|
assert body["deliberation"]["outcome"] == "teiku"
|
||||||
|
assert body["deliberation"]["panel_size"] == 1
|
||||||
|
# the one judge abstained: an abstaining majority of one, no dissent
|
||||||
|
assert body["deliberation"]["majority"]["votes"] == 1
|
||||||
|
assert body["deliberation"]["majority"]["reasoning"] =~ "no consultation"
|
||||||
|
assert body["deliberation"]["minority"]["votes"] == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tablet 55: with a consultation behind the judges, the dialectic
|
||||||
|
# decides — interpret resolved, challenge finds a kushya, resolve
|
||||||
|
# resolved → 2 accept, 1 reject; the minority preserved with its verse.
|
||||||
|
test "POST /chatzer/sanhedrin monetary panel splits 2–1 from the dialectic" do
|
||||||
|
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
|
||||||
|
write_provision(2)
|
||||||
|
|
||||||
|
try do
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
|
||||||
|
case: "devarim 6:4 — is the unity declared or commanded?",
|
||||||
|
complexity_hint: "monetary"
|
||||||
|
}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
d = Jason.decode!(conn.resp_body)["deliberation"]
|
||||||
|
assert d["panel_size"] == 3
|
||||||
|
assert d["outcome"] == "accepted"
|
||||||
|
assert d["majority"]["votes"] == 2
|
||||||
|
assert d["majority"]["position"] == "the reading"
|
||||||
|
assert d["majority"]["sources"] == ["bereishit 1:1"]
|
||||||
|
assert d["minority"]["votes"] == 1
|
||||||
|
assert d["minority"]["position"] == "but the verse says otherwise"
|
||||||
|
assert d["minority"]["sources"] == ["devarim 6:4"]
|
||||||
|
after
|
||||||
|
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devarim 17:9 — the decision is engraved in L3: an interpretation row
|
||||||
|
# with the panel and the votes, and the minority beside it.
|
||||||
|
@tag :l3
|
||||||
|
test "POST /chatzer/sanhedrin engraves the decision and the minority in L3" do
|
||||||
|
# The record is written from the request process (the router), so a
|
||||||
|
# checkout here covers it; the sandbox is shared for the sake of the
|
||||||
|
# panel's processes, which do not touch the Repo.
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()})
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end)
|
||||||
|
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
|
||||||
|
write_provision(2)
|
||||||
|
|
||||||
|
try do
|
||||||
|
before = VavPipeline.L3.Store.count_interpretations()
|
||||||
|
before_min = VavPipeline.L3.Store.count_minority_opinions()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "devarim 6:4", complexity_hint: "monetary"}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = Jason.decode!(conn.resp_body)
|
||||||
|
assert body["grounding"]["recorded"]["l3"] == "recorded"
|
||||||
|
assert VavPipeline.L3.Store.count_interpretations() == before + 1
|
||||||
|
assert VavPipeline.L3.Store.count_minority_opinions() == before_min + 1
|
||||||
|
after
|
||||||
|
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A panel is manna (Vayakhel §3.7): the day's sanhedrin_panels_max spent → 503.
|
||||||
|
test "POST /chatzer/sanhedrin with the day's panels spent returns 503 MANNA_DEPLETED" do
|
||||||
|
write_provision(1)
|
||||||
|
body = Jason.encode!(%{case: "bereishit 1:1"})
|
||||||
|
|
||||||
|
post = fn ->
|
||||||
|
conn(:post, "/chatzer/sanhedrin", body)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert post.().status == 200
|
||||||
|
second = post.()
|
||||||
|
assert second.status == 503
|
||||||
|
assert Jason.decode!(second.resp_body)["error"]["code"] == "MANNA_DEPLETED"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST /chatzer/sanhedrin with an unknown complexity_hint returns 422" do
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", complexity_hint: "cosmic"}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 422
|
||||||
|
assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "complexity_hint"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do
|
||||||
|
for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", sides: bad}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 422, "expected 422 for sides=#{inspect(bad)}, got #{conn.status}"
|
||||||
|
assert Jason.decode!(conn.resp_body)["error"]["code"] == "KIYOR_VALIDATION_FAILED"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
defmodule VavPipeline.Tablet55EngineRootNameTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tablet 55: the Talmudic Engine names roots by letter (bet_resh_aleph,
|
||||||
|
read from "bet-resh-aleph"). The pipeline was sending it the Hebrew,
|
||||||
|
which its verse facts never match — roots in, no verses out, and a
|
||||||
|
"resolved" reading over the emptiness.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias VavPipeline.BereishitCycle
|
||||||
|
|
||||||
|
test "from the Shoresh Engine's letter names" do
|
||||||
|
assert BereishitCycle.engine_root_name(%{shoresh: "ברא", shoresh_letters: ["bet", "resh", "aleph"]}) ==
|
||||||
|
"bet-resh-aleph"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "from the Hebrew alone (the L1 fallback), finals folded" do
|
||||||
|
assert BereishitCycle.engine_root_name(%{shoresh: "דבר", shoresh_letters: []}) == "dalet-bet-resh"
|
||||||
|
assert BereishitCycle.engine_root_name(%{shoresh: "מלך"}) == "mem-lamed-kaf"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "nothing nameable → nil, never a Hebrew string the engine cannot read" do
|
||||||
|
assert BereishitCycle.engine_root_name(%{shoresh: nil}) == nil
|
||||||
|
assert BereishitCycle.engine_root_name(%{shoresh: "x1"}) == nil
|
||||||
|
assert BereishitCycle.engine_root_name(%{}) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user