Files
bikoret/vav_pipeline/lib/vav_pipeline/gate/stream.ex
T
Scott KarpandClaude Fable 5.1 dc6316f1d7 sugya, step 3: the case behind each threshold is carried on every verdict
specs/sugya.md §IV "carried on the verdict", §V step 3. Sugya.for_verdict/1
renders, for every rule that fired and every form that was noted: kind,
verse, precedent, the dissents and exceptions — each with `applied`,
read from the record and never guessed: the single witness noted (and
for which forms), the hedged claims left out of the trace, grounding
not assessed for want of ground, a forged fringe standing on one
witness. A dissent the record cannot speak to is carried applied:
false — considered, and the rule's verse decided. The minority is on
every record (Eduyot 1:5–6): to be relied on later, and to be located.

Carried as `sugya` on the bedikah response, the pair's two sides, and
the gate's `bikoret` object (pass and stream). The fringe is unchanged
— it is for the reader, who asks for the sugya by reading the record.
Tamid §1.1: the `sugya` field in the response and the paragraph "Every
rule carries its sugya"; Doorpost pin → 658005c9…. Elixir 664/0
(sugya_verdict_test: clean → {}; noted apology → two-witness dissent
applied for that form; forged fringe → the mark exception applied;
golden calf with and without a hedged claim; a rule the record cannot
speak to → considered, applied false).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 01:30:09 +00:00

297 lines
11 KiB
Elixir

defmodule VavPipeline.Gate.Stream do
@moduledoc """
The gate for a streaming client (Construction §3.10, "streaming").
The model's events are relayed to the caller as they arrive — bytes
unchanged — while the text is gathered. When the model's stream ends,
the gathered text is examined like any reply, and before the terminal
events are released the fringe is sent as one more text block:
Anthropic `content_block_start` / `content_block_delta` /
`content_block_stop`; OpenAI one more chunk with `delta.content`. The
A forged fringe in a streamed reply (Tablet 53) has already reached
the client by the time it is read; it cannot be stripped, so the real
fringe names it (form forged_fringe) and the last mark is the gate's.
The `bikoret` object rides on the terminal event the client already parses
(`message_delta`; the chunk with `finish_reason`), where an unknown
field is ignored by every SDK.
Because the status line and headers are sent before the model has
spoken, a streaming reply carries no `X-Bikoret-*` headers and cannot
be rebuked (nothing streamed can be taken back); `X-Bikoret-Rebuke`
with `stream: true` is refused at the door. If the examination itself
fails after the stream has begun, the trailer says so — the reply is
delivered, marked unexamined, never silently.
"""
alias VavPipeline.{Anchors, Bedikah, Gate}
alias VavPipeline.Gate.{Shape, Upstream}
@idle_ms 120_000
@max_text 65_536
@type callbacks :: %{
start: (list() -> term()),
chunk: (term(), iodata() -> term())
}
@doc """
Stream `body` to `upstream` under `key`. `callbacks.start` is called
once with the upstream's response headers when the model answers 200
and must return an emitter; `callbacks.chunk` is called with the
emitter and each piece of SSE text to deliver. Returns
`{:ok, %{status: 200, bedikah: result | nil, ...}}` after the stream,
`{:ok, %{status: s, reply: map}}` for a non-200 (nothing started), or
`{:error, reason}` / `{:waiting, why}`.
"""
def pass(upstream, body, key, callbacks, opts \\ []) do
extra = Keyword.get(opts, :extra_headers, [])
examine_opts = Keyword.drop(opts, [:extra_headers])
body = Shape.unfringed(upstream, body)
with :ok <- Gate.gather_call() do
case Upstream.open_stream(upstream, body, key, extra) do
{:ok, ref} ->
state = %{
upstream: upstream,
body: body,
ref: ref,
buffer: "",
text: [],
text_len: 0,
held: [],
max_index: -1,
model: nil,
emitter: nil,
callbacks: callbacks,
examine_opts: examine_opts
}
loop(state)
{:error, reason} ->
{:error, :upstream_unavailable, reason}
end
end
end
# ------------------------------------------------------------
# The relay
# ------------------------------------------------------------
defp loop(%{ref: ref} = state) do
receive do
{:http, {^ref, :stream_start, headers}} ->
loop(%{state | emitter: state.callbacks.start.(headers)})
{:http, {^ref, :stream, part}} ->
loop(absorb(state, part))
{:http, {^ref, :stream_end, _headers}} ->
finish(state)
{:http, {^ref, {{_, status, _}, _headers, reply}}} ->
# A non-200 arrives whole; nothing has been sent to the caller yet.
case Jason.decode(reply) do
{:ok, decoded} when is_map(decoded) -> {:ok, %{status: status, reply: decoded, bedikah: nil, bikoret: nil}}
_ -> {:error, :upstream_unavailable, "upstream #{state.upstream} answered #{status} with a body that is not a JSON object"}
end
{:http, {^ref, {:error, reason}}} ->
if state.emitter, do: finish(state, "upstream stream failed: #{inspect(reason)}"), else: {:error, :upstream_unavailable, "upstream #{state.upstream} unreachable: #{inspect(reason)}"}
after
@idle_ms ->
:httpc.cancel_request(ref)
if state.emitter, do: finish(state, "upstream stream idle for #{div(@idle_ms, 1000)} s"), else: {:error, :upstream_unavailable, "upstream #{state.upstream} idle for #{div(@idle_ms, 1000)} s"}
end
end
# Split the buffer into complete SSE events; relay the ordinary ones, hold the terminal ones.
defp absorb(state, part) do
{events, rest} = split_events(state.buffer <> part)
state = %{state | buffer: rest}
Enum.reduce(events, state, &handle_event(&2, &1))
end
defp split_events(buffer) do
parts = String.split(buffer, ~r/\r?\n\r?\n/)
{complete, [rest]} = Enum.split(parts, length(parts) - 1)
{Enum.reject(complete, &(&1 == "")), rest}
end
defp handle_event(state, raw) do
{event, data} = parse_event(raw)
cond do
terminal?(state.upstream, event, data) ->
%{state | held: state.held ++ [raw]}
true ->
state = gather(state, event, data)
emit(state, raw <> "\n\n")
end
end
defp parse_event(raw) do
lines = String.split(raw, ~r/\r?\n/)
event = lines |> Enum.find_value(fn "event:" <> e -> String.trim(e); _ -> nil end)
data =
lines
|> Enum.flat_map(fn "data:" <> d -> [String.trim_leading(d)]; _ -> [] end)
|> Enum.join("\n")
{event, data}
end
# Anthropic ends with message_delta then message_stop; OpenAI with the finish_reason chunk then [DONE].
defp terminal?(:anthropic, event, _data), do: event in ["message_delta", "message_stop"]
defp terminal?(:openai, _event, "[DONE]"), do: true
defp terminal?(:openai, _event, data) do
case Jason.decode(data) do
{:ok, %{"choices" => [%{"finish_reason" => r} | _]}} when not is_nil(r) -> true
_ -> false
end
end
defp gather(state, "message_start", data) do
case Jason.decode(data) do
{:ok, %{"message" => %{"model" => m}}} -> %{state | model: m}
_ -> state
end
end
defp gather(%{upstream: :anthropic} = state, "content_block_start", data) do
case Jason.decode(data) do
{:ok, %{"index" => i}} -> %{state | max_index: max(state.max_index, i)}
_ -> state
end
end
defp gather(%{upstream: :anthropic} = state, "content_block_delta", data) do
case Jason.decode(data) do
{:ok, %{"delta" => %{"type" => "text_delta", "text" => t}}} -> add_text(state, t)
_ -> state
end
end
defp gather(%{upstream: :openai} = state, _event, data) do
case Jason.decode(data) do
{:ok, %{"choices" => [%{"delta" => %{"content" => t}} | _]} = chunk} when is_binary(t) ->
state = if state.model, do: state, else: %{state | model: chunk["model"]}
add_text(state, t)
{:ok, %{"model" => m}} when is_binary(m) and is_nil(state.model) ->
%{state | model: m}
_ ->
state
end
end
defp gather(state, _event, _data), do: state
defp add_text(state, t) when state.text_len + byte_size(t) > @max_text, do: state
defp add_text(state, t), do: %{state | text: [state.text | [t]], text_len: state.text_len + byte_size(t)}
defp emit(%{emitter: nil} = state, _data), do: state
defp emit(state, data), do: %{state | emitter: state.callbacks.chunk.(state.emitter, data)}
# ------------------------------------------------------------
# The end of the stream: examine, fringe, release
# ------------------------------------------------------------
defp finish(state, failure \\ nil) do
text = state.text |> IO.iodata_to_binary()
examined = if failure || String.trim(text) == "", do: nil, else: examine(state, text)
{trailer, bikoret} =
case examined do
{:ok, result} ->
anchors = Anchors.for_violations(result.violations)
{Shape.trailer(result, anchors, nil),
%{
bedikah_ref: result.bedikah_ref,
verdict: Atom.to_string(result.verdict),
violations: result.violations,
anchors: anchors,
fired: Shape.fired(result, anchors),
sugya: VavPipeline.Sugya.for_verdict(result),
sources: result.sources,
model: state.model,
streamed: true
}}
nil when not is_nil(failure) ->
{"\n\n— bikoret: not examined (#{failure})", %{verdict: "none", reason: failure, streamed: true}}
nil ->
# A turn with no text (tool use only) is not a reply to a reader: no block, the object says so.
{nil, %{verdict: "none", reason: "no text", streamed: true}}
{:waiting, why} ->
{"\n\n— bikoret: not examined (manna: #{why})", %{verdict: "none", reason: "manna #{why}", streamed: true}}
{:error, kind, reason} ->
{"\n\n— bikoret: not examined (#{kind}: #{reason})", %{verdict: "none", reason: "#{kind}: #{reason}", streamed: true}}
end
state = if trailer, do: inject(state, trailer, bikoret), else: state
state = Enum.reduce(state.held, state, fn raw, st -> emit(st, with_object(st.upstream, raw, bikoret) <> "\n\n") end)
result = case examined, do: ({:ok, r} -> r; _ -> nil)
{:ok, %{status: 200, bedikah: result, bikoret: bikoret, emitter: state.emitter}}
end
defp examine(state, text) do
provenance = Shape.provenance(state.upstream)
query = Shape.query(state.upstream, state.body)
sources = Shape.sources(state.upstream, state.body)
Bedikah.examine(text, provenance, query, Keyword.put(state.examine_opts, :sources, sources))
end
# The fringe as one more block of the kind the client is already reading.
defp inject(%{upstream: :anthropic} = state, trailer, _bikoret) do
i = state.max_index + 1
state
|> emit(sse("content_block_start", %{type: "content_block_start", index: i, content_block: %{type: "text", text: ""}}))
|> emit(sse("content_block_delta", %{type: "content_block_delta", index: i, delta: %{type: "text_delta", text: trailer}}))
|> emit(sse("content_block_stop", %{type: "content_block_stop", index: i}))
end
defp inject(%{upstream: :openai} = state, trailer, _bikoret) do
chunk = %{id: "bikoret", object: "chat.completion.chunk", model: state.model, choices: [%{index: 0, delta: %{content: trailer}, finish_reason: nil}]}
emit(state, "data: " <> Jason.encode!(chunk) <> "\n\n")
end
defp sse(event, map), do: "event: #{event}\ndata: " <> Jason.encode!(map) <> "\n\n"
# The object rides on the terminal event: message_delta (Anthropic), the finish chunk (OpenAI).
defp with_object(:anthropic, raw, bikoret) do
{event, data} = parse_event(raw)
if event == "message_delta" do
case Jason.decode(data) do
{:ok, map} -> sse("message_delta", Map.put(map, "bikoret", bikoret)) |> String.trim_trailing()
_ -> raw
end
else
raw
end
end
defp with_object(:openai, raw, bikoret) do
{_event, data} = parse_event(raw)
case Jason.decode(data) do
{:ok, %{"choices" => [%{"finish_reason" => r} | _]} = map} when not is_nil(r) ->
"data: " <> Jason.encode!(Map.put(map, "bikoret", bikoret))
_ ->
raw
end
end
end