Skip to content

Commit 062cb12

Browse files
committed
feat(s032): the smoke path reports EXLA, a vector search in the binary, and the tier's status; exla declared on Linux and macOS hosts only
AC7's fallback clause: no loadable extension, so the packaged binary proves the vector store in force with three fake vectors rolled back (binding, exit 4), and records whether the EXLA NIF loaded and what the semantic tier's status is in the bundle (informative). xla ships no Windows archive, so a Windows host does not declare exla and the tier is off there by design. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 6a6d5cf commit 062cb12

6 files changed

Lines changed: 272 additions & 90 deletions

File tree

‎.github/workflows/package.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ jobs:
198198
grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log
199199
# Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise).
200200
grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log
201+
# Slice 032, AC7: a fake-vector search ran inside this binary through the vector
202+
# store in force (exit 4 otherwise). The EXLA and SEMANTIC lines are recorded, not
203+
# asserted: whether the XLA library loads in Burrito's ERTS is what they measure.
204+
grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log
205+
grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log
201206
echo "--- ours, before ---"; cat ps-before.txt
202207
echo "--- ours, after ----"; cat ps-after.txt
203208
diff ps-before.txt ps-after.txt
@@ -215,6 +220,11 @@ jobs:
215220
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) {
216221
throw "the markdown renderer did not render in this binary (slice 013)"
217222
}
223+
# Slice 032, AC7, as on the other two: the vector search binds, the other two lines are recorded.
224+
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_VEC=ok:')) {
225+
throw "the vector search did not run inside this binary (slice 032)"
226+
}
227+
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)='
218228
219229
# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
220230
# `bash` on every OS, Windows included: the job that never curled was the one gap in

‎lib/trinity/memory/semantic.ex‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,55 @@ defmodule Trinity.Memory.Semantic do
177177
@spec get(String.t()) :: Entry.t() | nil
178178
def get(id), do: Repo.get_by(Entry, id: id, tier: "semantic")
179179

180+
@doc """
181+
The packaged binary's vector check (slice 032, AC7, `--smoke`): three rows of the fake
182+
embedder's deterministic vectors on a throwaway persona, the nearest expected first through
183+
the store in force, all rolled back. `{:ok, store}` or `{:error, reason}`.
184+
"""
185+
@spec smoke() :: {:ok, module()} | {:error, term()}
186+
def smoke do
187+
alias Trinity.Memory.Embedders.Fake
188+
189+
Repo.transaction(fn ->
190+
{:ok, persona} =
191+
Trinity.Personas.create(%{name: "smoke-#{System.unique_integer([:positive])}"})
192+
193+
scope = AlwaysOn.persona_scope(persona.id)
194+
texts = ["the cat sat", "quarterly tax filing", "a dog barked"]
195+
196+
ids =
197+
for {t, i} <- Enum.with_index(texts) do
198+
attrs = %{persona_id: persona.id, scope: scope, key: "k#{i}", body: t}
199+
{:ok, e} = %Entry{} |> Entry.semantic_changeset(attrs) |> Repo.insert()
200+
:ok = VectorStore.upsert(e.id, Fake.vector(t), Fake.model_id())
201+
e.id
202+
end
203+
204+
filter = %{persona_id: persona.id, scopes: [scope], model: Fake.model_id()}
205+
hits = VectorStore.search(Fake.vector("a dog barked"), 3, filter)
206+
207+
expected = Enum.at(ids, 2)
208+
209+
result =
210+
case hits do
211+
[%{id: ^expected, score: score}, _, _] when score > 0.999 ->
212+
{:ok, VectorStore.impl()}
213+
214+
_ ->
215+
{:error, {:wrong_order, Enum.map(hits, &{&1.entry.body, Float.round(&1.score, 3)})}}
216+
end
217+
218+
Repo.rollback(result)
219+
end)
220+
|> case do
221+
{:error, {:ok, store}} -> {:ok, store}
222+
{:error, {:error, reason}} -> {:error, reason}
223+
other -> {:error, other}
224+
end
225+
rescue
226+
e -> {:error, Exception.message(e)}
227+
end
228+
180229
defp vector_for(body, nil), do: Embedder.embed([body])
181230
defp vector_for(_body, vector) when is_list(vector), do: {:ok, [vector]}
182231

‎lib/trinity/smoke.ex‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,77 @@ defmodule Trinity.Smoke do
7474
end
7575
end
7676

77+
@doc """
78+
The third line (slice 032): whether the EXLA NIF loaded in this binary and ran one
79+
operation. Informative, not binding: a bundle whose XLA library does not load (Burrito's
80+
musl ERTS against a glibc `.so`, NOTES finding 13's shape) still boots with the semantic
81+
tier off, and AC7 asks that the failure be recorded by name. `ok` or `failed:<reason>`.
82+
"""
83+
@spec exla_line() :: String.t()
84+
def exla_line do
85+
if Code.ensure_loaded?(EXLA) do
86+
try do
87+
t = Nx.tensor([1.0, 2.0], backend: EXLA.Backend)
88+
[3.0] = Nx.to_flat_list(Nx.sum(t))
89+
"TRINITY_SMOKE_EXLA=ok"
90+
rescue
91+
e -> "TRINITY_SMOKE_EXLA=failed:#{inspect(Exception.message(e) |> String.slice(0, 200))}"
92+
catch
93+
kind, reason ->
94+
"TRINITY_SMOKE_EXLA=failed:#{inspect({kind, reason}) |> String.slice(0, 200)}"
95+
end
96+
else
97+
"TRINITY_SMOKE_EXLA=failed:not_compiled"
98+
end
99+
end
100+
101+
@doc """
102+
The fourth line (slice 032, AC7): a fake-vector search inside this binary through the
103+
vector store in force, on the database the binary opened, rolled back. Three rows of the
104+
suite's deterministic vectors, the nearest expected first; `ok:<store>` or
105+
`failed:<reason>`. Binding: exit 4 when it fails. The tier's own status follows as the
106+
fifth line, informative (`on`, or the reason it is off on this machine).
107+
"""
108+
@spec vec_line() :: String.t()
109+
def vec_line do
110+
case Trinity.Memory.Semantic.smoke() do
111+
{:ok, store} -> "TRINITY_SMOKE_VEC=ok:#{inspect(store)}"
112+
{:error, reason} -> "TRINITY_SMOKE_VEC=failed:#{inspect(reason) |> String.slice(0, 200)}"
113+
end
114+
end
115+
116+
@doc "The fifth line: the semantic tier's status in this binary, informative."
117+
@spec semantic_line() :: String.t()
118+
def semantic_line do
119+
case Trinity.Memory.Semantic.status() do
120+
:on -> "TRINITY_SMOKE_SEMANTIC=on"
121+
{:off, reason} -> "TRINITY_SMOKE_SEMANTIC=off:#{inspect(reason) |> String.slice(0, 200)}"
122+
end
123+
end
124+
77125
@doc """
78126
Runs the smoke check: report the listening port, then stop the OS process.
79127
80128
`say` and `halt` are injected so the whole path is exercisable from a test without ending
81129
the test runner's own OS process.
82130
"""
83-
@spec run(say_fun(), halt_fun(), String.t()) :: any()
84-
def run(say \\ &IO.puts/1, halt \\ &System.halt/1, markdown \\ markdown_line()) do
131+
@spec run(say_fun(), halt_fun(), String.t(), [String.t()]) :: any()
132+
def run(say \\ &IO.puts/1, halt \\ &System.halt/1, markdown \\ markdown_line(), rest \\ nil) do
85133
{:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http)
86134
say.(port_line(port))
87135
say.(markdown)
88-
halt.(if markdown == "TRINITY_SMOKE_MARKDOWN=ok", do: 0, else: 3)
136+
[exla, vec, semantic] = rest || [exla_line(), vec_line(), semantic_line()]
137+
say.(exla)
138+
say.(vec)
139+
say.(semantic)
140+
141+
halt.(
142+
cond do
143+
markdown != "TRINITY_SMOKE_MARKDOWN=ok" -> 3
144+
not String.starts_with?(vec, "TRINITY_SMOKE_VEC=ok:") -> 4
145+
true -> 0
146+
end
147+
)
89148
end
90149

91150
@doc """
@@ -109,7 +168,11 @@ defmodule Trinity.Smoke do
109168
def children(args) do
110169
if requested?(args) do
111170
markdown = markdown_line()
112-
[{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown) end}]
171+
# The 032 lines need the Repo and the memory supervisor, which have started by now;
172+
# they are cheap (one tiny tensor, three rows rolled back) and computed here for the
173+
# same race the markdown line lost once.
174+
rest = [exla_line(), vec_line(), semantic_line()]
175+
[{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown, rest) end}]
113176
else
114177
[]
115178
end

‎mix.exs‎

Lines changed: 97 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -89,90 +89,104 @@ defmodule Trinity.MixProject do
8989
# Specifies your project dependencies.
9090
#
9191
# Type `mix help deps` for examples and options.
92+
# Slice 032: EXLA is the local embedder's backend and has no Windows build (xla 0.10.0
93+
# ships archives for Linux and macOS, x86_64 and aarch64, none for Windows, and building
94+
# XLA from source needs Bazel), so a Windows host does not declare it: the tree compiles
95+
# there, `Trinity.Memory.Embedders.Bumblebee.availability/0` answers
96+
# `{:off, :no_local_backend}`, and the semantic tier is off until a Windows backend exists
97+
# (NOTES decision 3). The lock file carries exla either way; `mix deps.get` leaves an
98+
# undeclared lock entry alone.
99+
defp exla_deps do
100+
case :os.type() do
101+
{:win32, _} -> []
102+
_ -> [{:exla, "~> 0.13.1"}]
103+
end
104+
end
105+
92106
defp deps do
93-
[
94-
{:phoenix, "~> 1.8.13"},
95-
{:phoenix_ecto, "~> 4.5"},
96-
{:ecto_sql, "~> 3.13"},
97-
{:ecto_sqlite3, ">= 0.0.0"},
98-
# Slice 010: the CI-tested alternative behind TRINITY_DB=postgres (docs/adr/0002).
99-
# Optional so the standalone desktop build carries no Postgres driver; the CI matrix
100-
# job compiles with the variable set and proves the migrations on both.
101-
{:postgrex, ">= 0.0.0", optional: true},
102-
# Slice 011: the provider layer behind Trinity.LLM (docs/adr/0003). What it brings into
103-
# mix.lock is counted in the slice's NOTES.md, because the desktop binary carries it.
104-
{:req_llm, "~> 1.22"},
105-
# Slice 013: the chat's markdown renderer, behind TrinityWeb.Markdown. Chosen by the
106-
# measurement in the slice's NOTES.md; it brings a Rust NIF (mdex_native), precompiled.
107-
{:mdex, "~> 0.13"},
108-
# Slice 020: JSON Schema validation of tool arguments (Trinity.Tools.Schema). Already in
109-
# the lock through req_llm; direct because a module of ours calls it (ADR-0009).
110-
{:jsv, "~> 0.23"},
111-
# Slice 021: RFC 8785 canonical JSON under every approval fingerprint (docs/07). Chosen
112-
# by the measurement in the slice's NOTES.md; the RFC's vector is a test in the tree.
113-
{:jcs, "~> 0.2"},
114-
# Slice 022: HTML to text for web_fetch (Trinity.Tools.Web.Fetch).
115-
{:floki, "~> 0.38"},
116-
# Slice 032: local embeddings (Trinity.Memory.Embedders.Bumblebee). The 0.13 line of nx
117-
# and exla is what bumblebee 0.7.1 accepts (nx 1.0.0 shipped 2026-09-10 and bumblebee has
118-
# no release for it at G1); measured for bundle size and latency in the slice's NOTES.md.
119-
{:nx, "~> 0.13.1"},
120-
{:exla, "~> 0.13.1"},
121-
{:bumblebee, "~> 0.7.1"},
122-
# Slice 032: vectors on the Postgres job (Trinity.Memory.VectorStores.Pgvector).
123-
{:pgvector, "~> 0.4.1"},
124-
# Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from
125-
# source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs),
126-
# because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13).
127-
# rustler_precompiled needs Rustler present to run that build; build time only.
128-
{:rustler, "~> 0.38", runtime: false},
129-
{:phoenix_html, "~> 4.1"},
130-
{:phoenix_live_reload, "~> 1.2", only: :dev},
131-
{:phoenix_live_view, "~> 1.2.0"},
132-
{:lazy_html, ">= 0.1.0", only: :test},
133-
{:phoenix_live_dashboard, "~> 0.8.3"},
134-
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
135-
{:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
136-
{:heroicons,
137-
github: "tailwindlabs/heroicons",
138-
tag: "v2.2.0",
139-
sparse: "optimized",
140-
app: false,
141-
compile: false,
142-
depth: 1},
143-
{:daisyui,
144-
github: "saadeghi/daisyui",
145-
tag: "v5.5.20",
146-
sparse: "packages/bundle",
147-
app: false,
148-
compile: false,
149-
depth: 1},
150-
{:telemetry_metrics, "~> 1.0"},
151-
{:telemetry_poller, "~> 1.0"},
152-
{:gettext, "~> 1.0"},
153-
{:jason, "~> 1.2"},
154-
{:dns_cluster, "~> 0.2.0"},
155-
{:bandit, "~> 1.5"},
156-
# Slice 000: the gate's own tooling. Nothing else yet.
157-
{:boundary, "~> 0.10", runtime: false},
158-
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
159-
{:mox, "~> 1.2", only: :test},
160-
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
161-
{:sobelow, "~> 0.15", only: [:dev, :test], runtime: false},
162-
{:ex_doc, "~> 0.38", only: :dev, runtime: false},
163-
{:nimble_options, "~> 1.1"},
164-
# Slice 001 line 1, arm (a) recorded `only: :dev`. **Corrected at G4**, and the reason is
165-
# the shell, not the tooling: `ExTauri.ShutdownManager` is the sidecar's heartbeat, the
166-
# Rust window's only way to tell the BEAM it has closed, so it has to exist in the
167-
# binary that ships, and a `:dev`-only dependency does not. `mix ex_tauri.install` adds
168-
# that child unconditionally, which is why the generator's output could not start under
169-
# MIX_ENV=test or MIX_ENV=prod. Recorded as deviation D7 in NOTES.md.
170-
{:ex_tauri, "~> 0.2"},
171-
# Slice 001 line 3. `ex_tauri` already depends on burrito, but only in :dev, and
172-
# `&Burrito.wrap/1` is a release step that runs under MIX_ENV=prod. Declared directly so
173-
# the module exists in the environment that calls it.
174-
{:burrito, "~> 1.6"}
175-
] ++ posix_deps()
107+
exla_deps() ++
108+
[
109+
{:phoenix, "~> 1.8.13"},
110+
{:phoenix_ecto, "~> 4.5"},
111+
{:ecto_sql, "~> 3.13"},
112+
{:ecto_sqlite3, ">= 0.0.0"},
113+
# Slice 010: the CI-tested alternative behind TRINITY_DB=postgres (docs/adr/0002).
114+
# Optional so the standalone desktop build carries no Postgres driver; the CI matrix
115+
# job compiles with the variable set and proves the migrations on both.
116+
{:postgrex, ">= 0.0.0", optional: true},
117+
# Slice 011: the provider layer behind Trinity.LLM (docs/adr/0003). What it brings into
118+
# mix.lock is counted in the slice's NOTES.md, because the desktop binary carries it.
119+
{:req_llm, "~> 1.22"},
120+
# Slice 013: the chat's markdown renderer, behind TrinityWeb.Markdown. Chosen by the
121+
# measurement in the slice's NOTES.md; it brings a Rust NIF (mdex_native), precompiled.
122+
{:mdex, "~> 0.13"},
123+
# Slice 020: JSON Schema validation of tool arguments (Trinity.Tools.Schema). Already in
124+
# the lock through req_llm; direct because a module of ours calls it (ADR-0009).
125+
{:jsv, "~> 0.23"},
126+
# Slice 021: RFC 8785 canonical JSON under every approval fingerprint (docs/07). Chosen
127+
# by the measurement in the slice's NOTES.md; the RFC's vector is a test in the tree.
128+
{:jcs, "~> 0.2"},
129+
# Slice 022: HTML to text for web_fetch (Trinity.Tools.Web.Fetch).
130+
{:floki, "~> 0.38"},
131+
# Slice 032: local embeddings (Trinity.Memory.Embedders.Bumblebee). The 0.13 line of nx
132+
# and exla is what bumblebee 0.7.1 accepts (nx 1.0.0 shipped 2026-09-10 and bumblebee has
133+
# no release for it at G1); measured for bundle size and latency in the slice's NOTES.md.
134+
{:nx, "~> 0.13.1"},
135+
{:bumblebee, "~> 0.7.1"},
136+
# Slice 032: vectors on the Postgres job (Trinity.Memory.VectorStores.Pgvector).
137+
{:pgvector, "~> 0.4.1"},
138+
# Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from
139+
# source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs),
140+
# because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13).
141+
# rustler_precompiled needs Rustler present to run that build; build time only.
142+
{:rustler, "~> 0.38", runtime: false},
143+
{:phoenix_html, "~> 4.1"},
144+
{:phoenix_live_reload, "~> 1.2", only: :dev},
145+
{:phoenix_live_view, "~> 1.2.0"},
146+
{:lazy_html, ">= 0.1.0", only: :test},
147+
{:phoenix_live_dashboard, "~> 0.8.3"},
148+
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
149+
{:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
150+
{:heroicons,
151+
github: "tailwindlabs/heroicons",
152+
tag: "v2.2.0",
153+
sparse: "optimized",
154+
app: false,
155+
compile: false,
156+
depth: 1},
157+
{:daisyui,
158+
github: "saadeghi/daisyui",
159+
tag: "v5.5.20",
160+
sparse: "packages/bundle",
161+
app: false,
162+
compile: false,
163+
depth: 1},
164+
{:telemetry_metrics, "~> 1.0"},
165+
{:telemetry_poller, "~> 1.0"},
166+
{:gettext, "~> 1.0"},
167+
{:jason, "~> 1.2"},
168+
{:dns_cluster, "~> 0.2.0"},
169+
{:bandit, "~> 1.5"},
170+
# Slice 000: the gate's own tooling. Nothing else yet.
171+
{:boundary, "~> 0.10", runtime: false},
172+
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
173+
{:mox, "~> 1.2", only: :test},
174+
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
175+
{:sobelow, "~> 0.15", only: [:dev, :test], runtime: false},
176+
{:ex_doc, "~> 0.38", only: :dev, runtime: false},
177+
{:nimble_options, "~> 1.1"},
178+
# Slice 001 line 1, arm (a) recorded `only: :dev`. **Corrected at G4**, and the reason is
179+
# the shell, not the tooling: `ExTauri.ShutdownManager` is the sidecar's heartbeat, the
180+
# Rust window's only way to tell the BEAM it has closed, so it has to exist in the
181+
# binary that ships, and a `:dev`-only dependency does not. `mix ex_tauri.install` adds
182+
# that child unconditionally, which is why the generator's output could not start under
183+
# MIX_ENV=test or MIX_ENV=prod. Recorded as deviation D7 in NOTES.md.
184+
{:ex_tauri, "~> 0.2"},
185+
# Slice 001 line 3. `ex_tauri` already depends on burrito, but only in :dev, and
186+
# `&Burrito.wrap/1` is a release step that runs under MIX_ENV=prod. Declared directly so
187+
# the module exists in the environment that calls it.
188+
{:burrito, "~> 1.6"}
189+
] ++ posix_deps()
176190
end
177191

178192
# Slice 022: the shell tool's process wrapper is a C port built with elixir_make (fork,

0 commit comments

Comments
 (0)