diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 8dcadab..c0b1b0c 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -60,7 +60,9 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:17 + # Slice 032: pgvector's image of Postgres 17, so the vector extension exists for the + # semantic memory's Postgres store. + image: pgvector/pgvector:pg17 env: POSTGRES_USER: trinity POSTGRES_PASSWORD: trinity diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index c7eab89..0c7fe35 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -192,12 +192,20 @@ jobs: ours() { ps -eo pid,ppid,comm | grep -E 'burrito|beam\.smp|erl_child_setup|desktop_' | grep -v grep || true; } ours > ps-before.txt chmod +x "burrito_out/${{ matrix.artifact }}" - "./burrito_out/${{ matrix.artifact }}" --no-halt --smoke | tee smoke.log + # Slice 032: the variable, not `--smoke`: Kernel.CLI read the argument as a file + # ("No file named --smoke", exit 1) and won the race against the halting Task on + # macOS in run 35608084951. + TRINITY_SMOKE=1 "./burrito_out/${{ matrix.artifact }}" --no-halt | tee smoke.log sleep 3 ours > ps-after.txt grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log # Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise). grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log + # Slice 032, AC7: a fake-vector search ran inside this binary through the vector + # store in force (exit 4 otherwise). The EXLA and SEMANTIC lines are recorded, not + # asserted: whether the XLA library loads in Burrito's ERTS is what they measure. + grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log + grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log echo "--- ours, before ---"; cat ps-before.txt echo "--- ours, after ----"; cat ps-after.txt diff ps-before.txt ps-after.txt @@ -207,7 +215,8 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - & ".\burrito_out\${{ matrix.artifact }}" --no-halt --smoke | Tee-Object smoke.log + $env:TRINITY_SMOKE = "1" + & ".\burrito_out\${{ matrix.artifact }}" --no-halt | Tee-Object smoke.log if ($LASTEXITCODE -ne 0) { throw "smoke exited $LASTEXITCODE" } if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_PORT=\d+$')) { throw "no port line" @@ -215,6 +224,11 @@ jobs: if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) { throw "the markdown renderer did not render in this binary (slice 013)" } + # Slice 032, AC7, as on the other two: the vector search binds, the other two lines are recorded. + if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_VEC=ok:')) { + throw "the vector search did not run inside this binary (slice 032)" + } + Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' # AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped. # `bash` on every OS, Windows included: the job that never curled was the one gap in diff --git a/ROADMAP.md b/ROADMAP.md index 700364a..c52eeac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ standards register names the rows that ask for them. | 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned | | 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | approved | | 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved | -| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned | +| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | done | | 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | approved | | 034 | Export, import, restore | 3 Memory | S | 030, 031 | approved | | 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | planned | diff --git a/VERSIONS.md b/VERSIONS.md index 3182990..29c5965 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -87,7 +87,7 @@ never pin a version hex marks as retired or vulnerable. | `ecto_sql` | ~> 3.13 | βœ… in `mix.lock` | | | `ecto_sqlite3` | >= 0.0.0 | βœ… in `mix.lock` | Primary DB. FTS5 available. | | `postgrex` | >= 0.0.0 (optional) | βœ… in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. | -| `pgvector` | optional, ~> 0.3 | πŸ” not yet a dependency | Vectors on the Postgres path. Not yet a dependency; Slice 032 decides. Split from the postgrex row at Slice 010. | +| `pgvector` | ~> 0.4.1 | βœ… in `mix.lock` | Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010. | | `oban` | ~> 2.24 | πŸ” not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. | | `req` | ~> 0.5 | βœ… in `mix.lock` | HTTP client. | | `req_llm` | ~> 1.22 | βœ… in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. | @@ -118,10 +118,11 @@ never pin a version hex marks as retired or vulnerable. | Name | Pin | Verified | Note | |---|---|---|---| -| `nx, exla` | latest stable | πŸ” not a single package | Local embeddings. EXLA binary size matters for desktop: measure in 032. Two packages, so no single lock key. | -| `bumblebee` | ~> 0.7 | πŸ” not yet a dependency | `all-MiniLM-L6-v2` embeddings; Whisper later. Added at Slice 032. | -| `sqlite_vec` | ~> 0.1 | πŸ” not yet a dependency | Vectors in SQLite. Verify the loadable extension works inside the Burrito bundle (Slice 032). ⚠️ Pre-1.0, no release in roughly 22 months, 6,938 downloads all-time. R11's trigger already fires. Decide the fallback before Slice 032 starts. | -| `hnswlib` | ~> 0.1.7 | πŸ” not yet a dependency | ⚠️ Pre-1.0. Optional accelerator; not on the critical path. | +| `nx` | ~> 0.13.1 | βœ… in `mix.lock` | Tensors for the local embedder (Slice 032). The 0.13 line, not 1.0.0 (2026-09-10): bumblebee 0.7.1 accepts `~> 0.12 or ~> 0.13` and has no release for 1.0. Measured at Slice 032: docs/perf.md. | +| `exla` | ~> 0.13.1 | βœ… in `mix.lock` | The XLA backend the serving compiles to (Slice 032); precompiled for x86_64 and aarch64 Linux and macOS, none for Windows (not declared on a Windows host; the tier is off there). Declared `runtime: false` and carried by the release in `:load` mode, started on demand by `Trinity.Memory.Embedders.Bumblebee.exla/0`: its NIF does not load in Burrito's Linux ERTS (NOTES finding 2). The XLA shared library is 463 MB on disk: docs/perf.md. Pulls `xla` 0.10.0 and `fine`. | +| `bumblebee` | ~> 0.7.1 | βœ… in `mix.lock` | `all-MiniLM-L6-v2` embeddings through `Trinity.Memory.Embedders.Bumblebee` (Slice 032); Whisper later. Pulls `axon` 0.8.1 and `tokenizers` 0.5.1 (a precompiled Rust NIF). | +| `sqlite_vec` | not used (decided at Slice 032, 2026-09-21) | πŸ” not a single package | Vectors in SQLite. Its only release (0.1.0, 2024-11-19) requires `nx ~> 0.9` and cannot sit in a tree with nx 0.13; R11's trigger fired and the owner chose brute force in Elixir over the persona's semantic rows (`Trinity.Memory.VectorStores.Brute`, docs/02: fine to about 10^5) with pgvector on the Postgres path. The row stays so the decision is visible where a reader would look for the package. | +| `hnswlib` | ~> 0.1.10 | πŸ” not yet a dependency | ⚠️ Pre-1.0 (0.1.10, 2026-09-17, active). The scale option after brute force; not yet a dependency, the slice that measures brute force past 10^5 rows adds it. | ### Tools, sandbox and desktop diff --git a/config/config.exs b/config/config.exs index bc1c9ea..51fdeea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -73,6 +73,8 @@ config :trinity, :tools, Trinity.Tools.Web.Search, # Slice 031: full-text search over past messages. Trinity.Tools.SessionSearch, + # Slice 032: hybrid recall over semantic memories and past messages. + Trinity.Tools.Recall, # Slice 030: the always-on memory tiers. Trinity.Tools.Memory, Trinity.Tools.Shell.Run @@ -82,7 +84,7 @@ config :trinity, :tools, web: ["web_fetch", "web_search"], shell: ["shell"], # Slice 031: search over past conversations. - memory: ["session_search", "memory"] + memory: ["session_search", "recall", "memory"] } # Slice 022: the filesystem roots beside the data directory (always a root) and the session's diff --git a/config/runtime.exs b/config/runtime.exs index 8f1c15f..0383395 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -24,7 +24,9 @@ import Config # which is the same call: this file is evaluated before the application is available under # Mix, so it may not call project modules. `Burrito.Util.Args.get_arguments/0` reads the same # thing. -smoke? = "--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) +smoke? = + "--smoke" in Enum.map(:init.get_plain_arguments(), &to_string/1) or + System.get_env("TRINITY_SMOKE") == "1" if System.get_env("PHX_SERVER") || smoke? do config :trinity, TrinityWeb.Endpoint, server: true @@ -119,7 +121,9 @@ if config_env() == :prod do if System.get_env("TRINITY_DB") == "postgres" do config :trinity, Trinity.Repo, url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"), - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + # Slice 032: pgvector's `vector` type. + types: Trinity.Repo.PostgrexTypes config :trinity, Trinity.Repo.Receipts, url: System.get_env("DATABASE_URL"), diff --git a/config/test.exs b/config/test.exs index 6299861..8c78701 100644 --- a/config/test.exs +++ b/config/test.exs @@ -27,6 +27,8 @@ config :trinity, :tools, Trinity.Tools.Web.Search, # Slice 031: full-text search over past messages. Trinity.Tools.SessionSearch, + # Slice 032: hybrid recall. + Trinity.Tools.Recall, # Slice 030: the always-on memory tiers. Trinity.Tools.Memory, Trinity.Tools.Shell.Run @@ -37,7 +39,7 @@ config :trinity, :tools, web: ["web_fetch", "web_search"], shell: ["shell"], # Slice 031: search over past conversations. - memory: ["session_search", "memory"] + memory: ["session_search", "recall", "memory"] }, timeout_ms: 2_000 @@ -97,21 +99,36 @@ if System.get_env("TRINITY_DB") == "postgres" do config :trinity, Trinity.Repo, url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"), pool_size: 10, - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + # Slice 032: pgvector's `vector` type. + types: Trinity.Repo.PostgrexTypes, + # Slice 032: the sandbox shares one connection with every process a test starts, and + # DBConnection sheds load (refuses checkouts) when the queue stays over `queue_target` + # for `queue_interval`. The 100-session and 20-writer tests queue that hard on the + # postgres job's runner, more so once each turn also runs the retriever (runs 35610164189 + # and 35610171809); a queue that is long on purpose should wait, not shed. + queue_target: 5_000, + queue_interval: 30_000 # Slice 024: the receipts Repo shares the Postgres database (its own migrations table). config :trinity, Trinity.Repo.Receipts, url: System.get_env("DATABASE_URL"), pool_size: 10, - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + queue_target: 5_000, + queue_interval: 30_000 else config :trinity, Trinity.Repo, database: Path.expand("../trinity_test.db", __DIR__), - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + queue_target: 5_000, + queue_interval: 30_000 config :trinity, Trinity.Repo.Receipts, database: Path.expand("../trinity_test_receipts.db", __DIR__), - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + queue_target: 5_000, + queue_interval: 30_000 end # We don't run a server during test. If one is required, @@ -150,3 +167,7 @@ config :phoenix, # Slice 024: the receipt signing key and registry for the suite live under the project's # ignored tmp/, never in the data directory of the machine running the tests. config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__) + +# Slice 032: the suite embeds with the deterministic fake; nothing leaves the machine and no +# model is needed. +config :trinity, :memory, embedder: :fake, observer: false diff --git a/coverage.tsv b/coverage.tsv index dc0ca76..20f5d31 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -15,3 +15,4 @@ slice_id percent sha date 030 78.40 c657da8 2026-09-21 033 78.50 3b5e89e 2026-09-21 034 78.83 3a5785f 2026-09-21 +032 78.99 193800e 2026-09-21 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 5a923bf..777ec89 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -42,7 +42,12 @@ Trinity.Application β”‚ # INSERT but does not guarantee each row read the correct predecessor. β”œβ”€β”€ Trinity.Effects.Boot # Slice 024, as built: a transient Task writing the boot receipt β”‚ # once the signer and the authority are known -β”œβ”€β”€ Trinity.Memory.Supervisor # Nx.Serving for embeddings, retrieval. Slice 032 +β”œβ”€β”€ Trinity.Memory.Supervisor # Slice 032, as built: Trinity.Memory.TaskSupervisor (the +β”‚ β”‚ # observer's task after each turn, the page's model download) +β”‚ └── Nx.Serving (Trinity.Memory.Embedding) # the local embedder, started only when the embedder is +β”‚ # :local and the model is in the cache; absent otherwise, the +β”‚ # tier off and the reason logged once. ensure_embedding/0 +β”‚ # starts it after a download. β”œβ”€β”€ Trinity.Skills.Registry # hot-loaded skills index. Slice 040 β”œβ”€β”€ Oban # cron + durable jobs. Slice 050 β”œβ”€β”€ Trinity.MCP.Supervisor # MCP clients (one per server, lib per 059) + server. Slice 059-062 @@ -106,7 +111,8 @@ through those modules only. `boundary` `exports:` lists enforce this. | `Trinity.LLM.Provider` | `stream/3`, `generate/3`, `embed/2`, `models/0`, `capabilities/1` | config `:providers` list | | `Trinity.Tools.Tool` | `name/0`, `description/0`, `schema/0`, `risk/0`, `execute/2` | config `:tools` list + MCP dynamic registration | | `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0` | config `:gateways` list | -| `Trinity.Memory.VectorStore` | `upsert/2`, `search/3`, `delete/1` | config `:vector_store` (`SqliteVec` \| `Pgvector` \| `Hnswlib`) | +| `Trinity.Memory.VectorStore` | `upsert/3`, `search/3`, `delete/1`, `count/1` (as built at 032: the scope filter is `search/3`'s required argument) | the database adapter, at compile time (`Brute` on SQLite, `Pgvector` on Postgres; `Hnswlib` when a slice measures past 10^5 rows) | +| `Trinity.Memory.Embedder` | `embed/1`, `dim/0`, `model_id/0`, `availability/0` | `config :trinity, :memory, embedder:` (`:local` default, `:hosted` opt-in only, `:fake` in the suite) | | `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` | | `Trinity.Permissions.Policy` | `decide/3` β†’ `:allow | :deny | {:ask, prompt}` | config | @@ -120,7 +126,7 @@ UI/Gateway ──user_message──▢ Session(gen_statem) Session: append message β†’ build prompt (persona + always-on memory + skills index + history[compacted]) β†’ LLM.stream/3 ──chunks──▢ PubSub "session:" ──▢ LiveView + gateways β†’ on tool_call: Permissions.decide β†’ (ask β†’ approval_wait) β†’ Tools.execute in Task β†’ result appended - β†’ loop until final text β†’ persist β†’ Memory.observe(turn) (async) β†’ idle + β†’ loop until final text β†’ persist β†’ Memory.Observer.observe(turn) (a Task under Trinity.Memory.TaskSupervisor, as built at 032) β†’ idle ``` Every state transition is persisted before it is broadcast. A crash between persist and broadcast is safe diff --git a/docs/02-tech-stack.md b/docs/02-tech-stack.md index eda331f..46c0ab8 100644 --- a/docs/02-tech-stack.md +++ b/docs/02-tech-stack.md @@ -14,6 +14,7 @@ Versions live in `VERSIONS.md`. This file explains *why* each choice was made an | MCP | decided by Slice 059 (fastest_mcp / gen_mcp / anubis_mcp / own server) | Target is 2026-07-28 with 2025-11-25 compat; anubis is ≀ 2025-11-25 and LGPL-3.0 | ADR-0007 finalised by measurement | | Embeddings | Bumblebee + EXLA (all-MiniLM-L6-v2) | Local, private, 384-dim | If EXLA binary size is unacceptable on desktop β†’ hosted embeddings via req_llm, or Ortex (risk: stalled) | | Vector search | sqlite_vec (brute force) behind `VectorStore` behaviour | Fine to ~10^5 vectors; no extra process | Scale β†’ hnswlib (pre-1.0) or pgvector HNSW | +| Vector search, as built at 032 | brute force in Elixir over the row's `embedding` bytes (SQLite); pgvector HNSW (Postgres) | sqlite_vec's one release needs `nx ~> 0.9` and cannot share a tree with nx 0.13 (VERSIONS.md); measured in docs/perf.md | Scale β†’ hnswlib 0.1.10 | | Shell tool | MuonTrap | Guaranteed child kill on process death; cgroups on Linux | none | | Sandbox | Luerl (`sandbox` pkg) | In-VM, reduction-limited, no OS access | Untrusted native code β†’ container/microVM (out of scope) | | Modularity | `boundary` + behaviours + `Registry` | Compile-time enforcement of context deps | none | diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 31efa25..17c2b45 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -75,6 +75,22 @@ vocabulary is `global | persona: | project: | session:` (a session `persona`, `global`), `key` is required for the two always-on tiers (`^[a-z0-9][a-z0-9_.-]{0,63}$`, unique with tier and scope), and the semantic tier's columns wait for 032. Two tables beside it: +As built at slice 032 (the semantic tier): no `memories_vec` virtual table and no `sqlite_vec` (its only release +needs `nx ~> 0.9`; NOTES decision 1). The vector is three columns on the row, on both databases: +`embedding` (binary: float32, little-endian, `dim * 4` bytes), `embedding_model` (the embedder that produced it, +e.g. `bumblebee:sentence-transformers/all-MiniLM-L6-v2`, `fake:sha256-384` in the suite) and `embedding_dim` +(integer), all nullable, under an index on (`persona_id`, `tier`, `embedding_model`). On Postgres the migration +also creates the `vector` extension, a fourth column `embedding_vector vector(384)` and an HNSW cosine index on +it where `tier = 'semantic'`; `Trinity.Memory.VectorStores.Pgvector` keeps it in step with the bytes column and +searches it with `<=>`, and `VectorStores.Brute` (SQLite) loads the filter's rows and scores them in Elixir. +A search takes the scope list as a required argument (M6) and runs only over rows whose `embedding_model` is the +embedder in force's: models are never mixed, a change of embedder is a re-embed. Semantic rows are written by +`Trinity.Memory.Semantic.add/2` (the observer after a turn, `by: "observer"`; the page, `by: "ui"`), keyed +`-<6 hex of its SHA-256>`, and every write is a `memory_changes` row (`add`, `remove`, `pin`). +The change log's `action` vocabulary grows by `pin` (a semantic memory promoted to `always_on` through +`AlwaysOn.add/2`, so the budget applies) and `by` by `observer`. Recall marks its memory hits' `last_used_at`, +which the retriever's recency decay reads. + ### memory_changes (Slice 030) `persona_id`, `action` (add | replace | remove | promote | consolidate), `tier`, `scope`, `key`, `before`, `after`, `by` (tool | ui | consolidator), `session_id`, `proposal_id`, `inserted_at`. Every write to the diff --git a/docs/packaging.md b/docs/packaging.md index 7cf2b6f..099b94c 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -66,6 +66,15 @@ nothing downstream notices. process. It is the only part of "the desktop app works" that a terminal can answer on a machine with no display. +As built at slice 032, `TRINITY_SMOKE=1` in the environment asks for the same run and is the form +the package workflow uses: `Kernel.CLI` reads the plain arguments once the application has started +and treats `--smoke` as a file to run ("No file named --smoke", exit 1), a race the halting Task +lost on macOS in run 35608084951. The run prints five lines: the port, the markdown renderer +(slice 013), whether the EXLA NIF loaded (`TRINITY_SMOKE_EXLA`, recorded), a fake-vector search +through the vector store in force on the binary's own database, rolled back (`TRINITY_SMOKE_VEC`, +binding: exit 4), and the semantic tier's status (`TRINITY_SMOKE_SEMANTIC`, recorded). Slice +032's PROOF.md AC7 has each bundle's answers. + ## Measurements: linux x86_64 only Machine: Linux 6.14.0-37-generic, x86_64. **These figures are for this host and this target.** diff --git a/docs/perf.md b/docs/perf.md new file mode 100644 index 0000000..92bd090 --- /dev/null +++ b/docs/perf.md @@ -0,0 +1,98 @@ +# Performance measurements + +Numbers this tree is built on, each with the command or run that produced it and the date. A slice that +changes a number appends a dated row; nothing here is typed from memory (CLAUDE.md section 8). + +## Semantic memory (slice 032) + +The local embedder is `sentence-transformers/all-MiniLM-L6-v2` through Bumblebee 0.7.1 and EXLA 0.13.1 +(`Trinity.Memory.Embedders.Bumblebee`), 384 dimensions, sequence length 128, mean pooling, L2-normalised. +Measured 2026-09-21 on the owner's machine (AMD Ryzen AI MAX+ 395, CPU only through EXLA's host client) +with `scripts/embed_bench.exs` (`BUMBLEBEE_CACHE_DIR= MIX_ENV=test mix run --no-start +scripts/embed_bench.exs`) unless a row names another command. + +### Embedding latency + +| measure | value | +|---|---| +| model download (weights and tokenizer, one entry in the cache) | 91,359,935 bytes | +| model load | 650 ms | +| first embed (XLA compilation) | 318 ms | +| single embed | 86.4 ms p50, 90.9 ms max over 20 | +| batch of 32 | 86 ms, 2.71 ms per item | +| cosine("the cat sat", "a cat was sitting") | 0.858 (AC2 asks > 0.7) | +| cosine("the cat sat", "quarterly tax filing") | 0.062 (AC2 asks < 0.3) | +| without EXLA (`Nx.BinaryBackend`) | 15 s first, 23 s per sentence: unusable, which is why the tier is off wherever EXLA cannot load | + +The hosted alternative (`nvidia:embed`, nemotron-3-embed-1b, opt-in only): 2048 dimensions, 208 ms p50 and +314 ms max over 5 single embeds, 627 ms for a batch of 32, and cosines 0.71 and 0.752 on the two pairs above: +its raw vectors do not separate at the slice's thresholds (slices/032-semantic-memory/NOTES.md). + +### Memory + +| measure | value | how | +|---|---|---| +| VM total / OS process RSS while embedding, in the bench script | 101 MB / 587 MB | `scripts/embed_bench.exs` | +| dev server RSS with the model loaded, idle after the AC6 run | 954,680 kB (peak 1,292,456 kB) | `ps -o rss= -p `; `/proc//status` VmHWM, 2026-09-21 | + +### Binary size + +| measure | value | +|---|---| +| XLA shared library on disk (`deps/exla/cache/.../libxla_extension.so`) | 463,283,344 bytes; 106,037,350 gzip-compressed; the download archive 144,319,267 | +| EXLA NIF (`libexla.so`) | 659,856 bytes | +| tokenizers NIF | 6.9 MB | +| precompiled XLA targets | x86_64 and aarch64 Linux, x86_64 and aarch64 macOS; none for Windows | + +Packaged binaries (the `package` workflow's artifacts, compressed by Burrito): + +| target | slice 034 (run 35556497526) | slice 032 (run 35604769356) | delta | +|---|---|---|---| +| linux x86_64 | 23,933,053 | 92,597,767 | +68,664,714 (the XLA library and the NIF, which do not load in this ERTS: NOTES finding 2) | +| macOS aarch64 | 15,176,156 | 60,544,615 | +45,368,459 (the NIF loads: `TRINITY_SMOKE_EXLA=ok`) | +| windows x86_64 | 27,551,320 | 29,640,626 | +2,089,306 (nx, bumblebee, the tokenizers NIF; no exla on a Windows host) | + +Sizes from `gh api repos/ScriptKittyOS/Trinity/actions/runs//artifacts`. + +### CI: EXLA on the gate's legs + +The first push with the dependency (run 35596759435, 2026-09-21), from the job logs' timestamps: + +| leg | what happened | time | +|---|---|---| +| gate (ubuntu) | xla archive unpacked, the NIF compiled with g++ against it, cached | 11:57:36 to 11:58:17: 41 s | +| postgres (ubuntu) | the same | 11:57:54 to 11:58:38: 44 s | +| fips (the container, its own toolchain) | the same, from source for the NIF; the XLA archive is the precompiled one | 11:57:45 to 11:58:55: 70 s | + +The XLA archive and the compiled NIF are cached by the runners' `actions/cache` keys, so later runs pay the +compile only when the cache misses. + +### Vector search + +`scripts/vector_bench.exs` (`MIX_ENV=test TRINITY_BENCH_DB= mix run --no-start +scripts/vector_bench.exs`): fake 384-dimension vectors, one persona, one scope, `Trinity.Memory.VectorStores.Brute` +on SQLite, 2026-09-21, the script's output: + +| rows | insert and store the vector, one transaction | first search (8 hits) | search p50 | search max | +|---|---|---|---|---| +| 1,000 | 77 ms | 475 ms (the EXLA compile for that shape) | 8.9 ms | 10.8 ms | +| 10,000 | 665 ms | 435 ms | 99.6 ms | 104.3 ms | + +The split at 10,000 rows, the same script: loading the rows from SQLite 74 ms; the cosines in Elixir over +decoded lists 390 ms; the EXLA product 27 ms first, 24 ms after. So the store scores with the EXLA product +when the NIF is loaded and falls back to the Elixir path (fine to about 10^4 rows) when it is not. An earlier +scratch run before the product was built measured the Elixir path at 531.7 ms p50 for 10,000 rows and +`Nx.dot` on `Nx.BinaryBackend` at 1,364 ms, which is why the binary backend is not the fallback. + +A vector is 1,536 bytes on the row (384 float32); 10,000 rows add 15,360,000 bytes of vectors, and the +scratch database file measured 47,702,016 bytes with 10,000 rows in it (`pragma page_count * page_size`). +docs/02's "fine to about 10^5" is the EXLA product's line, not the Elixir path's; hnswlib 0.1.10 is the step +after either. + +`Trinity.Memory.VectorStores.Pgvector` (Postgres 17 in the `pgvector/pgvector:pg17` image, the HNSW cosine +index, `<=>`), the same script against a local container on 2026-09-21: + +| rows | insert and store the vector, in one transaction (two statements a row) | search p50 (8 hits) | search max | +|---|---|---|---| +| 1,000 | 921 ms | 4.2 ms | 5.5 ms | +| 10,000 | 13,402 ms | 1.9 ms | 2.3 ms | diff --git a/lib/trinity.ex b/lib/trinity.ex index d1e0354..03386f1 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -11,7 +11,8 @@ defmodule Trinity do # Tools and Permissions sub-boundaries and, until 024, `Effects.Catalog` (now # `Tools.Catalog`, exported by Tools). Slice 024 exports Repo.Receipts (the Receipts # boundary writes it), CorePolicy (the boot receipt reads it) and the Receipts, Authority - # and Effects sub-boundaries. + # and Effects sub-boundaries. Slice 032 exports Memory.Semantic, Memory.Retriever and + # Memory.Embedders.Bumblebee (the memory page's semantic tab and its download action). use Boundary, deps: [], exports: @@ -41,6 +42,9 @@ defmodule Trinity do Memory.Entry, Memory.Proposal, Memory.Change, + Memory.Semantic, + Memory.Retriever, + Memory.Embedders.Bumblebee, Tools, Permissions, Permissions.Approval, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index eb2917c..c5653fa 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -44,6 +44,8 @@ defmodule Trinity.Application do {Registry, keys: :unique, name: Trinity.Registry}, # Slice 024: the signer and its key, the chain writers, the boot receipt. Before the # tools and the sessions, which receipt through it. + # Slice 032: the observer's task supervisor and the local embedding serving. + Trinity.Memory.Supervisor, Trinity.Receipts.Supervisor, # Slice 024: the boot receipt, once the signer and the authority are known. Trinity.Effects.Boot, @@ -52,7 +54,10 @@ defmodule Trinity.Application do Trinity.Tools.Supervisor, # Slice 021: approval requests and their decisions, with pending rows reloaded. Trinity.Permissions.Gate, - Trinity.Sessions.Supervisor, + Trinity.Sessions.Supervisor + ] ++ + Trinity.Smoke.probe(Trinity.Smoke.argv()) ++ + [ # Start to serve requests, typically the last entry TrinityWeb.Endpoint ] ++ Trinity.Smoke.children(Trinity.Smoke.argv()) diff --git a/lib/trinity/llm/providers/req_llm.ex b/lib/trinity/llm/providers/req_llm.ex index f77a32e..ad5f771 100644 --- a/lib/trinity/llm/providers/req_llm.ex +++ b/lib/trinity/llm/providers/req_llm.ex @@ -196,7 +196,9 @@ defmodule Trinity.LLM.Providers.ReqLLM do end) end - defp context(%Request{system: system, messages: messages}) do + @doc false + @spec context(Request.t()) :: ReqLLM.Context.t() + def context(%Request{system: system, messages: messages}) do base = if system, do: [ReqLLM.Context.system(system)], else: [] ReqLLM.Context.new(base ++ Enum.map(messages, &message/1)) end @@ -213,7 +215,11 @@ defmodule Trinity.LLM.Providers.ReqLLM do ReqLLM.Context.assistant(c) calls -> - ReqLLM.Context.assistant(c, tool_calls: Enum.map(calls, &{&1.id, &1.name, &1.args})) + # fix(s011) at slice 032: req_llm 1.24.0's normalize_tool_call/1 takes a map with + # `name` and `arguments` (the id kept), not the `{id, name, args}` tuple sent before. + ReqLLM.Context.assistant(c, + tool_calls: Enum.map(calls, &%{id: &1.id, name: &1.name, arguments: &1.args}) + ) end end diff --git a/lib/trinity/memory.ex b/lib/trinity/memory.ex index 2655735..331fe47 100644 --- a/lib/trinity/memory.ex +++ b/lib/trinity/memory.ex @@ -8,5 +8,23 @@ defmodule Trinity.Memory do """ use Boundary, deps: [Trinity, Trinity.LLM], - exports: [Tokens, Compactor, Search, AlwaysOn, Budget, Consolidator, Entry, Proposal, Change] + exports: [ + Tokens, + Compactor, + Search, + AlwaysOn, + Budget, + Consolidator, + Entry, + Proposal, + Change, + Embedder, + Embedders.Bumblebee, + Embedders.Fake, + Observer, + Retriever, + Semantic, + Supervisor, + VectorStore + ] end diff --git a/lib/trinity/memory/embedder.ex b/lib/trinity/memory/embedder.ex new file mode 100644 index 0000000..0b77e3f --- /dev/null +++ b/lib/trinity/memory/embedder.ex @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedder do + @moduledoc """ + Text to vectors (slice 032). One implementation is in force, chosen by + `config :trinity, :memory, embedder:`: `:local` (`Embedders.Bumblebee`, the default: + all-MiniLM-L6-v2 through Bumblebee and EXLA, on this machine), `:hosted` + (`Embedders.Hosted`, a configured alternative only: it sends text to a provider and is + never chosen for the operator), `:fake` (the suite). Every vector records the embedder's + `model_id/0` and `dim/0` on its row, and a store never mixes models (NOTES decision 4). + """ + + @type vector :: [float()] + + @doc "The vectors for texts, in order; `{:error, reason}` when the embedder cannot run." + @callback embed([String.t()]) :: {:ok, [vector()]} | {:error, term()} + + @doc "The dimension of this embedder's vectors." + @callback dim() :: pos_integer() + + @doc "The model's identifier as rows record it, e.g. `\"bumblebee:sentence-transformers/all-MiniLM-L6-v2\"`." + @callback model_id() :: String.t() + + @doc "`:ok`, or the reason this embedder cannot serve here (no model, no backend on this OS, not configured)." + @callback availability() :: :ok | {:off, term()} + + @doc "The configured choice: `:local` unless configuration says otherwise." + @spec configured() :: :local | :hosted | :fake + def configured, do: Application.get_env(:trinity, :memory, []) |> Keyword.get(:embedder, :local) + + @doc "The implementation the configuration names." + @spec impl() :: module() + def impl do + case configured() do + :local -> Trinity.Memory.Embedders.Bumblebee + :hosted -> Trinity.Memory.Embedders.Hosted + :fake -> Trinity.Memory.Embedders.Fake + end + end + + @doc "Embeds through the implementation in force." + @spec embed([String.t()]) :: {:ok, [vector()]} | {:error, term()} + def embed(texts) when is_list(texts), do: impl().embed(texts) + + @doc "Cosine similarity of two vectors of the same length (both L2-normalised: their dot)." + @spec cosine(vector(), vector()) :: float() + def cosine(a, b) when length(a) == length(b) do + {dot, na, nb} = + Enum.zip_reduce(a, b, {0.0, 0.0, 0.0}, fn x, y, {d, p, q} -> + {d + x * y, p + x * x, q + y * y} + end) + + if na == 0.0 or nb == 0.0, do: 0.0, else: dot / (:math.sqrt(na) * :math.sqrt(nb)) + end + + @doc "A vector as the row stores it: float32, little-endian." + @spec to_binary(vector()) :: binary() + def to_binary(vector), do: for(f <- vector, into: <<>>, do: <>) + + @doc "A stored vector back to floats." + @spec from_binary(binary()) :: vector() + def from_binary(bin), do: for(<>, do: f) +end diff --git a/lib/trinity/memory/embedders/bumblebee.ex b/lib/trinity/memory/embedders/bumblebee.ex new file mode 100644 index 0000000..509aa7b --- /dev/null +++ b/lib/trinity/memory/embedders/bumblebee.ex @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Bumblebee do + @moduledoc """ + all-MiniLM-L6-v2 on this machine through Bumblebee and EXLA (slice 032): 384 dimensions, + mean pooling, L2-normalised; measured at G1 at 86 ms a sentence and 2.7 ms an item in a + batch of 32, with cosines 0.858 and 0.062 on the slice's two pairs. The serving runs under + `Trinity.Memory.Supervisor` as `Trinity.Memory.Embedding` when the model is present. + + **The model is not downloaded on its own.** `availability/0` answers `{:off, :model_missing}` + until `download/0` (an explicit action, on the memory page or by `mix trinity.embeddings.download`) + has put the 91 MB of weights and tokenizer files under the model cache + (`config :trinity, :memory, model_cache_dir:`, the data directory's `models/` by default; + `BUMBLEBEE_CACHE_DIR` overrides). On Windows, where XLA has no precompiled library, + `{:off, :no_local_backend}`; a missing or broken EXLA NIF, `{:off, {:exla, reason}}`. None of + these turns into a hosted call (NOTES decision 2). + """ + @behaviour Trinity.Memory.Embedder + + @repo "sentence-transformers/all-MiniLM-L6-v2" + @dim 384 + @serving Trinity.Memory.Embedding + + @impl true + def dim, do: @dim + + @impl true + def model_id, do: "bumblebee:" <> @repo + + @doc "The Hugging Face repository the weights come from." + @spec repo() :: String.t() + def repo, do: @repo + + @doc "The serving's registered name." + @spec serving_name() :: atom() + def serving_name, do: @serving + + @doc "The model cache directory in force." + @spec cache_dir() :: Path.t() + def cache_dir do + System.get_env("BUMBLEBEE_CACHE_DIR") || + Application.get_env(:trinity, :memory, [])[:model_cache_dir] || + Path.join(Trinity.Paths.data_dir(), "models") + end + + @impl true + def availability do + cond do + match?({:win32, _}, :os.type()) -> {:off, :no_local_backend} + not Code.ensure_loaded?(EXLA) -> {:off, {:exla, :not_compiled}} + match?({:error, _}, exla()) -> {:off, elem(exla(), 1)} + not model_present?() -> {:off, :model_missing} + true -> :ok + end + end + + @doc """ + Starts the exla application if it is loaded and not running, once: the release loads it + without starting it (mix.exs), because its start loads the NIF and that load fails in + Burrito's Linux ERTS. `:ok`, or `{:error, {:exla, reason}}` remembered for the run (a NIF + that did not load will not load on the next call either). + """ + @spec exla() :: :ok | {:error, {:exla, term()}} + def exla do + case :persistent_term.get({__MODULE__, :exla}, nil) do + nil -> + result = + case Application.ensure_all_started(:exla) do + {:ok, _} -> :ok + {:error, reason} -> {:error, {:exla, summarise(reason)}} + end + + :persistent_term.put({__MODULE__, :exla}, result) + result + + known -> + known + end + end + + # The loader's message, not the whole start_link failure tree. + defp summarise({:exla, {reason, _}}), do: summarise(reason) + defp summarise({:shutdown, {:failed_to_start_child, _, reason}}), do: summarise(reason) + + defp summarise({:undef, [{EXLA.NIF, fun, _, _} | _]}), + do: "the EXLA NIF did not load (EXLA.NIF.#{fun} undefined)" + + defp summarise(reason) when is_binary(reason), do: String.slice(reason, 0, 200) + defp summarise(reason), do: reason |> inspect() |> String.slice(0, 200) + + @doc "True when the weights and tokenizer are in the cache (checked offline, no request made)." + @spec model_present?() :: boolean() + def model_present? do + case load(offline: true) do + {:ok, _, _} -> true + _ -> false + end + end + + @impl true + def embed(texts) do + case Process.whereis(@serving) do + nil -> + {:error, {:embedder_off, availability()}} + + _pid -> + try do + results = Nx.Serving.batched_run(@serving, texts) + {:ok, Enum.map(results, &Nx.to_flat_list(&1.embedding))} + catch + kind, reason -> {:error, {kind, reason}} + end + end + end + + @doc """ + The `Nx.Serving` for the supervisor, or `{:error, reason}` when the model is not present. + Loading takes about 650 ms; the first embed compiles the graph (318 ms measured). + """ + @spec serving() :: {:ok, Nx.Serving.t()} | {:error, term()} + def serving do + with {:ok, model_info, tokenizer} <- load(offline: true) do + {:ok, + Bumblebee.Text.text_embedding(model_info, tokenizer, + compile: [batch_size: 32, sequence_length: 128], + defn_options: [compiler: EXLA], + output_pool: :mean_pooling, + output_attribute: :hidden_state, + embedding_processor: :l2_norm + )} + end + end + + @doc """ + Downloads the model into the cache (a network egress the operator asked for), then asks + the supervisor to start the serving. Returns `:ok` or the download's error. + """ + @spec download() :: :ok | {:error, term()} + def download do + with {:ok, _, _} <- load(offline: false) do + Trinity.Memory.Supervisor.ensure_embedding() + end + end + + defp load(opts) do + repo = {:hf, @repo, cache_dir: cache_dir(), offline: Keyword.get(opts, :offline, true)} + + with {:ok, model_info} <- Bumblebee.load_model(repo), + {:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do + {:ok, model_info, tokenizer} + end + rescue + e -> {:error, {:load, Exception.message(e)}} + end +end diff --git a/lib/trinity/memory/embedders/fake.ex b/lib/trinity/memory/embedders/fake.ex new file mode 100644 index 0000000..7663352 --- /dev/null +++ b/lib/trinity/memory/embedders/fake.ex @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Fake do + @moduledoc """ + Deterministic vectors for the suite (slice 032): a text's SHA-256, taken twelve times + with a counter, expanded to 384 floats (the local model's width, so the Postgres job's + `vector(384)` column and index are the ones exercised) and L2-normalised. The same text + embeds the same way in every run, and two different texts land near orthogonal (their + cosine is a random walk over 384 signed bytes: within a few hundredths of zero). Texts that + share a `#near:` prefix map to nearby vectors (the key is what is hashed; the rest of + the text only nudges one component), so a dedupe test has something to find. + """ + @behaviour Trinity.Memory.Embedder + + @dim 384 + + @impl true + def dim, do: @dim + + @impl true + def model_id, do: "fake:sha256-#{@dim}" + + @impl true + def availability, do: :ok + + @impl true + def embed(texts), do: {:ok, Enum.map(texts, &vector/1)} + + @doc "The vector for one text." + @spec vector(String.t()) :: [float()] + def vector(text) do + {base, jitter} = + case Regex.run(~r/^#near:(\S+)\s*(.*)$/s, text) do + [_, key, rest] -> {key, byte_size(rest) * 0.01} + _ -> {text, 0.0} + end + + floats = + for i <- 0..11, <> <> base)>>, do: b / 255.0 - 0.5 + + floats = Enum.with_index(floats, fn f, i -> if i == 0, do: f + jitter, else: f end) + norm = :math.sqrt(Enum.sum(Enum.map(floats, &(&1 * &1)))) + Enum.map(floats, &(&1 / norm)) + end +end diff --git a/lib/trinity/memory/embedders/hosted.ex b/lib/trinity/memory/embedders/hosted.ex new file mode 100644 index 0000000..6b40520 --- /dev/null +++ b/lib/trinity/memory/embedders/hosted.ex @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Embedders.Hosted do + @moduledoc """ + A provider's embedding endpoint through `Trinity.LLM.embed/2` (slice 032). A configured + alternative only: `config :trinity, :memory, embedder: :hosted, hosted_model: "nvidia:embed"`. + Text sent here leaves the machine, so this module is never selected on the operator's + behalf (NOTES decisions 2 and 5), and `availability/0` says `{:off, :not_configured}` unless + the configuration names it. The dimension is the model's, read from the first vector and + kept for the run; `dim/0` before any call answers the configured `hosted_dim` (2048 for + nemotron-3-embed-1b, measured at G1). + """ + @behaviour Trinity.Memory.Embedder + + @dim_key {__MODULE__, :dim} + + @impl true + def dim do + :persistent_term.get( + @dim_key, + Application.get_env(:trinity, :memory, []) |> Keyword.get(:hosted_dim, 2048) + ) + end + + @impl true + def model_id, do: "hosted:" <> model() + + @impl true + def availability do + with :hosted <- Trinity.Memory.Embedder.configured(), + {:ok, %{caps: caps}} when is_list(caps) <- Trinity.LLM.Registry.lookup(model()) do + if :embed in caps, do: :ok, else: {:off, {:no_embed_capability, model()}} + else + {:error, reason} -> {:off, reason} + _ -> {:off, :not_configured} + end + end + + @impl true + def embed(texts) do + with :ok <- availability(), + {:ok, vectors} <- Trinity.LLM.embed(texts, model: model()) do + case vectors do + [v | _] -> :persistent_term.put(@dim_key, length(v)) + _ -> :ok + end + + {:ok, vectors} + end + end + + defp model, + do: Application.get_env(:trinity, :memory, []) |> Keyword.get(:hosted_model, "nvidia:embed") +end diff --git a/lib/trinity/memory/entry.ex b/lib/trinity/memory/entry.ex index 621cb46..f782a20 100644 --- a/lib/trinity/memory/entry.ex +++ b/lib/trinity/memory/entry.ex @@ -26,6 +26,11 @@ defmodule Trinity.Memory.Entry do field :source_message_id, Trinity.UUID field :confidence, :float field :last_used_at, :utc_datetime_usec + # Slice 032: the vector, the model that produced it (never mixed: docs/05 Β§ semantic) and + # its width. `nil` until the observer embeds the row. + field :embedding, :binary + field :embedding_model, :string + field :embedding_dim, :integer timestamps(type: :utc_datetime_usec) end @@ -48,7 +53,10 @@ defmodule Trinity.Memory.Entry do :body, :source_message_id, :confidence, - :last_used_at + :last_used_at, + :embedding, + :embedding_model, + :embedding_dim ]) |> validate_required([:persona_id, :tier, :scope, :key, :body]) |> validate_inclusion(:tier, @always_on_tiers) @@ -60,6 +68,38 @@ defmodule Trinity.Memory.Entry do |> unique_constraint([:tier, :scope, :key]) end + @doc """ + The changeset of a semantic memory (slice 032): the same fields, the tier fixed to + `semantic`, and `confidence` between 0 and 1. The always-on changeset refuses the tier so + the `memory` tool and the consolidator cannot write into it; pinning one (`Semantic.pin/2`) + goes through `changeset/2` with the always-on tier. + """ + @spec semantic_changeset(t(), map()) :: Ecto.Changeset.t() + def semantic_changeset(entry, attrs) do + entry + |> cast(attrs, [ + :persona_id, + :scope, + :key, + :body, + :source_message_id, + :confidence, + :last_used_at, + :embedding, + :embedding_model, + :embedding_dim + ]) + |> put_change(:tier, "semantic") + |> validate_required([:persona_id, :scope, :key, :body]) + |> validate_format(:key, ~r/^[a-z0-9][a-z0-9_.-]{0,63}$/, + message: "a short stable key: lowercase, digits, _ . -" + ) + |> validate_length(:body, min: 1, max: 4_000) + |> validate_format(:scope, ~r/^(global|persona:[^\s]+|project:[^\s]+|session:[^\s]+)$/) + |> validate_number(:confidence, greater_than_or_equal_to: 0, less_than_or_equal_to: 1) + |> unique_constraint([:tier, :scope, :key]) + end + @doc "Bytes an entry costs the budget: its key and body." @spec bytes(t()) :: non_neg_integer() def bytes(%__MODULE__{key: k, body: b}), do: byte_size(k) + byte_size(b) diff --git a/lib/trinity/memory/observer.ex b/lib/trinity/memory/observer.ex new file mode 100644 index 0000000..6b61b20 --- /dev/null +++ b/lib/trinity/memory/observer.ex @@ -0,0 +1,213 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Observer do + @moduledoc """ + Fills the semantic tier after a turn (slice 032). The session hands the completed turn's + messages to `observe/2`, which runs `run/2` under `Trinity.Memory.TaskSupervisor` so the + session is idle at once and a crash here is this task's alone. `run/2` asks the session's + own model (`Trinity.LLM.generate_object/3`, the one the operator chose for the conversation: + no text goes anywhere new, NOTES decision 5) for 0 to 3 durable facts, preferences or + decisions, embeds them in one batch, drops each whose cosine to a memory already in the + session's scope chain is at or over the dedupe threshold (0.92, `dedupe_cosine:`) or to + one earlier in the same batch, and inserts the rest as `semantic` rows in the persona's + scope with the source message, the model's confidence and a row in the change log + (`by: "observer"`). + + Off, and nothing runs, when the tier is off (`Trinity.Memory.Semantic.status/0`), when the + session has no persona, or when `config :trinity, :memory, observer: false` (the test + suite's default: the AC3 test calls `run/2` itself). + """ + + alias Trinity.LLM + alias Trinity.LLM.Request + alias Trinity.Memory.{AlwaysOn, Embedder, Entry, Semantic} + + require Logger + + @default_dedupe 0.92 + @max_memories 3 + + @schema %{ + "type" => "object", + "properties" => %{ + "memories" => %{ + "type" => "array", + "maxItems" => @max_memories, + "items" => %{ + "type" => "object", + "properties" => %{ + "kind" => %{"type" => "string", "enum" => ["fact", "preference", "decision"]}, + "body" => %{"type" => "string"}, + "confidence" => %{"type" => "number", "minimum" => 0, "maximum" => 1} + }, + "required" => ["kind", "body", "confidence"] + } + } + }, + "required" => ["memories"] + } + + @typedoc "What the session hands over: its id, its persona and its model." + @type turn :: %{session_id: String.t(), persona_id: String.t() | nil, model: String.t() | nil} + + @typedoc "A message of the turn, as the session's history carries it." + @type message :: %{id: String.t(), role: String.t(), content: String.t()} + + @doc "The JSON Schema the model answers with." + @spec schema() :: map() + def schema, do: @schema + + @doc "True when the observer will run for this turn." + @spec on?(turn()) :: boolean() + def on?(%{persona_id: persona_id}) do + is_binary(persona_id) and + Keyword.get(Application.get_env(:trinity, :memory, []), :observer, true) == true and + Semantic.on?() + end + + @doc "Runs `run/2` under the memory task supervisor; `:off` when the observer is off." + @spec observe(turn(), [message()]) :: {:ok, pid()} | :off + def observe(turn, messages) do + if on?(turn) do + Task.Supervisor.start_child(Trinity.Memory.TaskSupervisor, fn -> run(turn, messages) end) + else + :off + end + end + + @doc """ + Extracts, dedupes and stores; synchronous. Returns the entries inserted, `:off`, or the + model's error. The dedupe threshold is `dedupe_cosine:` in `config :trinity, :memory`. + """ + @spec run(turn(), [message()]) :: {:ok, [Entry.t()]} | :off | {:error, term()} + def run(%{persona_id: persona_id, session_id: session_id} = turn, messages) do + with true <- on?(turn) || :off, + {:ok, proposed} <- extract(turn, messages), + {:ok, vectors} <- Embedder.embed(Enum.map(proposed, & &1.body)) do + source = source_id(messages) + + inserted = + proposed + |> Enum.zip(vectors) + |> dedupe(persona_id, AlwaysOn.chain(persona_id, session_id)) + |> Enum.flat_map(fn {m, v} -> insert(m, v, persona_id, session_id, source) end) + + {:ok, inserted} + else + :off -> :off + {:error, reason} -> {:error, reason} + end + end + + # Drops a proposal whose vector is within the threshold of a memory in the chain or of one + # kept earlier in this batch; order preserved. + defp dedupe(pairs, persona_id, chain) do + threshold = + Keyword.get(Application.get_env(:trinity, :memory, []), :dedupe_cosine, @default_dedupe) + + {kept, _} = + Enum.reduce(pairs, {[], []}, fn {m, v}, {kept, seen} -> + if duplicate?(persona_id, chain, v, seen, threshold), + do: {kept, seen}, + else: {[{m, v} | kept], [v | seen]} + end) + + Enum.reverse(kept) + end + + defp insert(m, vector, persona_id, session_id, source) do + attrs = %{ + persona_id: persona_id, + scope: AlwaysOn.persona_scope(persona_id), + key: key(m.body), + body: m.body, + source_message_id: source, + confidence: m.confidence + } + + case Semantic.add(attrs, by: "observer", session_id: session_id, vector: vector) do + {:ok, entry} -> + [entry] + + {:error, reason} -> + Logger.debug("memory: observer skipped #{inspect(m.body)}: #{inspect(reason)}") + [] + end + end + + @doc "The key a body gets: its first words as a slug and six hex digits of its digest, so two wordings never collide and the same wording always meets `:exists`." + @spec key(String.t()) :: String.t() + def key(body) do + slug = + body + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + |> String.slice(0, 40) + |> String.trim("-") + + digest = :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) |> binary_part(0, 6) + if slug == "", do: "m-" <> digest, else: slug <> "-" <> digest + end + + # The turn, with roles, as the model reads it. Tool rows are long and rarely durable: + # named, not quoted. + defp extract(%{model: model, session_id: session_id}, messages) do + transcript = + Enum.map_join(messages, "\n\n", fn + %{role: "tool"} = m -> "[tool result: #{String.slice(m.content, 0, 200)}]" + m -> "#{m.role}: #{String.slice(m.content, 0, 4_000)}" + end) + + request = + Request.new!(%{ + model: model, + system: + "You watch a conversation between a person and their personal agent and note what is " <> + "worth remembering weeks from now: durable facts about the person or their work, their " <> + "preferences, and decisions they made. List 0 to #{@max_memories}. Skip anything transient, " <> + "anything about the agent itself, and anything a stranger would already know. Each memory " <> + "is one sentence in the third person, and confidence is how sure you are it is durable " <> + "and true (0 to 1). Invent nothing; an empty list is a fine answer.", + messages: [%{role: "user", content: transcript}] + }) + + case LLM.generate_object(request, @schema, session_id: session_id) do + {:ok, %{"memories" => memories}} when is_list(memories) -> + {:ok, memories |> Enum.flat_map(&clean/1) |> Enum.take(@max_memories)} + + {:ok, other} -> + {:error, {:no_memories, other}} + + {:error, reason} -> + {:error, reason} + end + end + + defp clean(%{"body" => body} = m) when is_binary(body) do + body = body |> String.trim() |> String.slice(0, 4_000) + + confidence = + case m["confidence"] do + n when is_number(n) -> n |> max(0) |> min(1) |> Kernel.*(1.0) + _ -> 0.5 + end + + if body == "", do: [], else: [%{body: body, confidence: confidence, kind: m["kind"]}] + end + + defp clean(_), do: [] + + defp duplicate?(persona_id, chain, vector, seen, threshold) do + Enum.any?(seen, &(Embedder.cosine(&1, vector) >= threshold)) or + Semantic.near(persona_id, chain, vector, threshold) != nil + end + + # Provenance: the assistant's last message of the turn, else the user's. + defp source_id(messages) do + case Enum.reverse(messages) do + [] -> nil + rev -> (Enum.find(rev, &(&1.role == "assistant")) || hd(rev)).id + end + end +end diff --git a/lib/trinity/memory/retriever.ex b/lib/trinity/memory/retriever.ex new file mode 100644 index 0000000..f339e64 --- /dev/null +++ b/lib/trinity/memory/retriever.ex @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Retriever do + @moduledoc """ + Hybrid recall (slice 032): what in the past is relevant to a query. Two ranked lists are + fused by reciprocal rank (`1 / (60 + rank)`, summed for an item in both): the vector + search over the persona's semantic memories in the session's scope chain (M6: never + wider) and the full-text search of 031 over the persona's past messages (the current + session's own rows are the history already and are left out). A recency decay then + scales each fused score by `0.5 + 0.5 * 2^(-age / 30 days)`, age being the time since a + memory was last used (or inserted) or a message was written: an old hit keeps at least + half its score, so it is demoted below a recent one of the same rank, never lost. + + A vector search always has `k` nearest rows, however far; a memory enters the vector list + only at a cosine of `recall_min_cosine:` (0.3) or more, the slice's own "unrelated" line + (AC2: the unrelated pair measures 0.062 on the local model, the related one 0.858), so a + question about nothing the person ever said recalls nothing. + + When the semantic tier is off, the vector list is empty and recall is the full-text list + alone: 031 keeps working (NOTES decision 3). Memories that come back are marked used. + """ + + alias Trinity.Memory.{AlwaysOn, Embedder, Search, Semantic, VectorStore} + + @rrf_k 60 + @half_life_days 30 + @default_k 8 + @default_min_cosine 0.3 + + @typedoc "A fused hit: a semantic memory or a past message, with the fused score and which lists found it." + @type hit :: %{ + kind: :memory | :message, + id: String.t(), + text: String.t(), + at: DateTime.t(), + score: float(), + found_by: [:vector | :fts], + ref: map() + } + + @doc """ + The `k` (#{@default_k}) most relevant hits for a query, best first. `opts`: `k:`, + `now:` (the decay's reference), `touch:` (mark memory hits used; true). + """ + @spec relevant(String.t(), String.t() | nil, String.t(), keyword()) :: [hit()] + def relevant(persona_id, session_id, query, opts \\ []) do + k = Keyword.get(opts, :k, @default_k) + now = Keyword.get(opts, :now) || DateTime.utc_now() + depth = max(k * 3, 20) + + vector = vector_hits(persona_id, session_id, query, depth) + fts = fts_hits(persona_id, session_id, query, depth) + + hits = + [{:vector, vector}, {:fts, fts}] + |> Enum.reduce(%{}, fn {source, list}, acc -> fuse(acc, source, list) end) + |> Map.values() + |> Enum.map(&%{&1 | score: &1.score * decay(&1.at, now)}) + |> Enum.sort_by(&{-&1.score, &1.id}) + |> Enum.take(k) + + if Keyword.get(opts, :touch, true) do + Semantic.touch(for %{kind: :memory, id: id} <- hits, do: id) + end + + hits + end + + @doc "The block the prompt carries: `## Relevant memories`, one line a hit; `\"\"` for none." + @spec render([hit()]) :: String.t() + def render([]), do: "" + + def render(hits) do + "## Relevant memories\n" <> + Enum.map_join(hits, "\n", fn + %{kind: :memory, at: at, text: text} -> + "- (remembered #{Calendar.strftime(at, "%Y-%m-%d")}) #{one_line(text)}" + + %{kind: :message, at: at, text: text, ref: ref} -> + "- (#{ref.role} in \"#{ref.session_title || "untitled"}\", #{Calendar.strftime(at, "%Y-%m-%d")}) #{one_line(text)}" + end) + end + + @doc "The reciprocal-rank contribution of a rank (1-based)." + @spec rrf(pos_integer()) :: float() + def rrf(rank), do: 1 / (@rrf_k + rank) + + @doc "The recency factor: 1 now, 0.75 at one half-life, never under 0.5." + @spec decay(DateTime.t(), DateTime.t()) :: float() + def decay(at, now) do + age_days = max(DateTime.diff(now, at, :second), 0) / 86_400 + 0.5 + 0.5 * :math.pow(2, -age_days / @half_life_days) + end + + # One list into the fused map: a first sighting takes its rank's share, a second adds it. + defp fuse(acc, source, list) do + list + |> Enum.with_index(1) + |> Enum.reduce(acc, fn {hit, rank}, acc -> + first = %{hit | score: rrf(rank), found_by: [source]} + again = &%{&1 | score: &1.score + rrf(rank), found_by: &1.found_by ++ [source]} + Map.update(acc, {hit.kind, hit.id}, first, again) + end) + end + + defp vector_hits(persona_id, session_id, query, depth) do + with true <- Semantic.on?(), + {:ok, [vector]} <- Embedder.embed([query]) do + chain = AlwaysOn.chain(persona_id, session_id) + + floor = + Keyword.get( + Application.get_env(:trinity, :memory, []), + :recall_min_cosine, + @default_min_cosine + ) + + vector + |> VectorStore.search(depth, Semantic.filter(persona_id, chain)) + |> Enum.filter(&(&1.score >= floor)) + |> Enum.map(fn %{entry: e, score: s} -> + %{ + kind: :memory, + id: e.id, + text: e.body, + at: e.last_used_at || e.inserted_at, + score: s, + found_by: [], + ref: %{key: e.key, scope: e.scope, source_message_id: e.source_message_id, cosine: s} + } + end) + else + _ -> [] + end + end + + defp fts_hits(persona_id, session_id, query, depth) do + query + |> Search.messages(limit: min(depth + 20, 100), persona_id: persona_id) + |> Enum.reject(&(&1.session_id == session_id)) + |> Enum.take(depth) + |> Enum.map(fn h -> + %{ + kind: :message, + id: h.message_id, + text: h.snippet, + at: h.inserted_at, + score: h.rank, + found_by: [], + ref: %{session_id: h.session_id, session_title: h.session_title, role: h.role, seq: h.seq} + } + end) + end + + defp one_line(text), + do: text |> String.replace(~r/\s+/, " ") |> String.trim() |> String.slice(0, 300) +end diff --git a/lib/trinity/memory/semantic.ex b/lib/trinity/memory/semantic.ex new file mode 100644 index 0000000..c2a05a7 --- /dev/null +++ b/lib/trinity/memory/semantic.ex @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Semantic do + @moduledoc """ + The semantic tier (slice 032): unbounded rows of `memories` with `tier: "semantic"`, each + carrying the vector its embedder produced, written by the observer after a turn and read by + the retriever and the `recall` tool. Every write is a row in the change log, as the + always-on tiers' are (030). + + Whether the tier is on is `status/0`: the embedder in force can serve. When it cannot, the + tier is off (the observer and the retriever stand down, the recall tool says so, the memory + page says "unavailable"), never rerouted to a hosted embedder (NOTES decision 3). The status + is computed from the embedder's `availability/0` and the serving's presence every time it + is asked; nothing is cached, so a download changes the answer. + """ + + import Ecto.Query + + alias Trinity.Memory.{AlwaysOn, Embedder, Entry, VectorStore} + alias Trinity.Repo + + @type status :: :on | {:off, term()} + + @doc "The tier's status now." + @spec status() :: status() + def status do + impl = Embedder.impl() + + case impl.availability() do + :ok -> if serving?(impl), do: :on, else: {:off, :serving_not_started} + {:off, reason} -> {:off, reason} + end + end + + @doc "True when the tier is on." + @spec on?() :: boolean() + def on?, do: status() == :on + + @doc "The status as a sentence for a page or a tool." + @spec describe(status()) :: String.t() + def describe(:on), do: "semantic recall is on (#{Embedder.impl().model_id()})" + + def describe({:off, :model_missing}), + do: "semantic recall is unavailable: the local model is not downloaded" + + def describe({:off, :no_local_backend}), + do: "semantic recall is unavailable on this platform: no local embedding backend yet" + + def describe({:off, :not_configured}), + do: "semantic recall is unavailable: the hosted embedder is not configured" + + def describe({:off, :serving_not_started}), + do: "semantic recall is unavailable: the embedding serving is not running" + + def describe({:off, reason}), do: "semantic recall is unavailable: #{inspect(reason)}" + + @doc "The model id rows written now carry; the filter every search runs under." + @spec model() :: String.t() + def model, do: Embedder.impl().model_id() + + @doc "The search filter for a persona and the scopes it may see (M6: never wider)." + @spec filter(String.t(), [String.t()]) :: VectorStore.filter() + def filter(persona_id, scopes), do: %{persona_id: persona_id, scopes: scopes, model: model()} + + @doc """ + Adds a semantic memory: embeds the body (or takes `vector:` when the caller embedded it + already, the observer's batch), inserts the row with its vector and the model that + produced it, logs the change (`by:`, `session_id:`). `{:error, {:embedder_off, _}}` when the + tier is off; `{:error, :exists}` when the key is taken in that scope. + """ + @spec add(map(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def add(attrs, opts) do + attrs = Map.new(attrs, fn {k, v} -> {to_atom(k), v} end) + + with nil <- AlwaysOn.get("semantic", attrs[:scope], attrs[:key]), + {:ok, [vector]} <- vector_for(attrs[:body], Keyword.get(opts, :vector)), + {:ok, entry} <- insert(attrs, vector) do + # The bytes column is on the row already (the changeset); the Postgres store also fills + # its vector column. `upsert/3` is the one place that knows about both. + :ok = VectorStore.upsert(entry.id, vector, model()) + AlwaysOn.log(entry, "add", nil, entry.body, Keyword.put_new(opts, :by, "observer")) + {:ok, Repo.reload!(entry)} + else + %Entry{} -> {:error, :exists} + {:error, reason} -> {:error, reason} + end + end + + @doc "The nearest existing memory to a vector within the scopes, when its cosine reaches `threshold`." + @spec near(String.t(), [String.t()], Embedder.vector(), float()) :: VectorStore.hit() | nil + def near(persona_id, scopes, vector, threshold) do + case VectorStore.search(vector, 1, filter(persona_id, scopes)) do + [%{score: score} = hit] when score >= threshold -> hit + _ -> nil + end + end + + @doc "Removes a semantic memory; logged." + @spec remove(Entry.t(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def remove(%Entry{tier: "semantic"} = entry, opts) do + with {:ok, deleted} <- Repo.delete(entry) do + AlwaysOn.log(entry, "remove", entry.body, nil, Keyword.put_new(opts, :by, "owner")) + {:ok, deleted} + end + end + + @doc """ + Pins a semantic memory: it becomes an `always_on` entry of the same scope and key through + `AlwaysOn.add/2` (the budget check and the consolidator apply), and the semantic row goes, + logged as `pin`. `{:error, :exists}` when the always-on tier has the key already. + """ + @spec pin(Entry.t(), keyword()) :: {:ok, Entry.t()} | {:error, term()} + def pin(%Entry{tier: "semantic"} = entry, opts) do + opts = Keyword.put_new(opts, :by, "owner") + + attrs = %{ + persona_id: entry.persona_id, + tier: "always_on", + scope: entry.scope, + key: entry.key, + body: entry.body, + source_message_id: entry.source_message_id, + confidence: entry.confidence + } + + with {:ok, pinned} <- AlwaysOn.add(attrs, opts), + {:ok, _} <- Repo.delete(entry) do + AlwaysOn.log(entry, "pin", entry.body, "always_on", opts) + {:ok, pinned} + end + end + + @doc "Marks memories as used now (the retriever's hits), so recency decay sees them." + @spec touch([String.t()]) :: :ok + def touch([]), do: :ok + + def touch(ids) do + Repo.update_all(from(e in Entry, where: e.id in ^ids), + set: [last_used_at: DateTime.utc_now()] + ) + + :ok + end + + @doc "A persona's semantic memories within the scopes, newest first (`limit:`)." + @spec entries(String.t(), [String.t()], keyword()) :: [Entry.t()] + def entries(persona_id, scopes, opts \\ []) do + from(e in Entry, + where: e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes, + order_by: [desc: e.inserted_at], + limit: ^Keyword.get(opts, :limit, 200) + ) + |> Repo.all() + end + + @doc "Every semantic memory of a persona, in all scopes, newest first (`limit:`; the memory page)." + @spec all(String.t(), keyword()) :: [Entry.t()] + def all(persona_id, opts \\ []) do + from(e in Entry, + where: e.persona_id == ^persona_id and e.tier == "semantic", + order_by: [desc: e.inserted_at], + limit: ^Keyword.get(opts, :limit, 200) + ) + |> Repo.all() + end + + @doc "How many semantic memories a persona has, in all scopes." + @spec count(String.t()) :: non_neg_integer() + def count(persona_id), + do: + Repo.aggregate( + from(e in Entry, where: e.persona_id == ^persona_id and e.tier == "semantic"), + :count + ) + + @doc "A semantic memory by id." + @spec get(String.t()) :: Entry.t() | nil + def get(id), do: Repo.get_by(Entry, id: id, tier: "semantic") + + @doc """ + The packaged binary's vector check (slice 032, AC7, `--smoke`): three rows of the fake + embedder's deterministic vectors on a throwaway persona, the nearest expected first through + the store in force, all rolled back. `{:ok, store}` or `{:error, reason}`. + """ + @spec smoke() :: {:ok, module()} | {:error, term()} + def smoke do + alias Trinity.Memory.Embedders.Fake + + Repo.transaction(fn -> + {:ok, persona} = + Trinity.Personas.create(%{name: "smoke-#{System.unique_integer([:positive])}"}) + + scope = AlwaysOn.persona_scope(persona.id) + texts = ["the cat sat", "quarterly tax filing", "a dog barked"] + + ids = + for {t, i} <- Enum.with_index(texts) do + attrs = %{persona_id: persona.id, scope: scope, key: "k#{i}", body: t} + {:ok, e} = %Entry{} |> Entry.semantic_changeset(attrs) |> Repo.insert() + :ok = VectorStore.upsert(e.id, Fake.vector(t), Fake.model_id()) + e.id + end + + filter = %{persona_id: persona.id, scopes: [scope], model: Fake.model_id()} + hits = VectorStore.search(Fake.vector("a dog barked"), 3, filter) + + expected = Enum.at(ids, 2) + + result = + case hits do + [%{id: ^expected, score: score}, _, _] when score > 0.999 -> + {:ok, VectorStore.impl()} + + _ -> + {:error, {:wrong_order, Enum.map(hits, &{&1.entry.body, Float.round(&1.score, 3)})}} + end + + Repo.rollback(result) + end) + |> case do + {:error, {:ok, store}} -> {:ok, store} + {:error, {:error, reason}} -> {:error, reason} + other -> {:error, other} + end + rescue + e -> {:error, Exception.message(e)} + end + + defp vector_for(body, nil), do: Embedder.embed([body]) + defp vector_for(_body, vector) when is_list(vector), do: {:ok, [vector]} + + defp insert(attrs, vector) do + attrs = + Map.merge(attrs, %{ + embedding: Embedder.to_binary(vector), + embedding_model: model(), + embedding_dim: length(vector) + }) + + %Entry{} |> Entry.semantic_changeset(attrs) |> Repo.insert() + end + + defp serving?(Trinity.Memory.Embedders.Bumblebee), + do: is_pid(Process.whereis(Trinity.Memory.Embedders.Bumblebee.serving_name())) + + defp serving?(_), do: true + + defp to_atom(k) when is_atom(k), do: k + defp to_atom(k) when is_binary(k), do: String.to_existing_atom(k) +end diff --git a/lib/trinity/memory/supervisor.ex b/lib/trinity/memory/supervisor.ex new file mode 100644 index 0000000..c328527 --- /dev/null +++ b/lib/trinity/memory/supervisor.ex @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.Supervisor do + @moduledoc """ + The memory side's processes (slice 032): the task supervisor the observer runs under, and + the embedding serving when the local embedder can serve. The serving is started at boot + when the model is present and on demand after a download (`ensure_embedding/0`); when it + cannot start, the tier is off and the reason is logged once, and the tree boots. + """ + use Supervisor + + require Logger + + alias Trinity.Memory.{Embedder, Embedders, Semantic} + + def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [{Task.Supervisor, name: Trinity.Memory.TaskSupervisor}] ++ embedding_children() + Supervisor.init(children, strategy: :one_for_one) + end + + @doc "Starts the local serving if the model is present and it is not running; reports the tier's status." + @spec ensure_embedding() :: :ok | {:error, term()} + def ensure_embedding do + case {Embedder.impl(), Process.whereis(Embedders.Bumblebee.serving_name())} do + {Embedders.Bumblebee, nil} -> + with {:ok, spec} <- embedding_child(), do: start_child(spec) + + _ -> + :ok + end + end + + defp start_child(spec) do + case Supervisor.start_child(__MODULE__, spec) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp embedding_children do + if Embedder.impl() == Embedders.Bumblebee do + case embedding_child() do + {:ok, spec} -> + [spec] + + {:error, reason} -> + Logger.info("memory: #{Semantic.describe({:off, reason})}") + [] + end + else + [] + end + end + + defp embedding_child do + with :ok <- Embedders.Bumblebee.availability(), + {:ok, serving} <- Embedders.Bumblebee.serving() do + {:ok, + Supervisor.child_spec( + {Nx.Serving, + serving: serving, + name: Embedders.Bumblebee.serving_name(), + batch_size: 32, + batch_timeout: 50}, + id: Trinity.Memory.Embedding + )} + else + {:off, reason} -> {:error, reason} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/trinity/memory/vector_store.ex b/lib/trinity/memory/vector_store.ex new file mode 100644 index 0000000..b3d9c40 --- /dev/null +++ b/lib/trinity/memory/vector_store.ex @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStore do + @moduledoc """ + Where semantic memories' vectors live and how they are searched (slice 032). The scope + filter is a required argument of `search/3` (M6): a search never sees a scope the caller + did not name. The store in force is the adapter's: `Brute` on SQLite (the persona's rows + of the embedder in force loaded and scored in Elixir; docs/02: fine to about 10^5) and + `Pgvector` on Postgres. Both search only rows whose `embedding_model` is the embedder in + force's (NOTES decision 4). + """ + + @type hit :: %{id: String.t(), score: float(), entry: Trinity.Memory.Entry.t()} + @type filter :: %{persona_id: String.t(), scopes: [String.t()], model: String.t()} + + @doc "Stores a vector on an existing semantic memory row." + @callback upsert(entry_id :: String.t(), vector :: [float()], model :: String.t()) :: + :ok | {:error, term()} + + @doc "The `k` nearest rows by cosine within the filter, best first." + @callback search(query :: [float()], k :: pos_integer(), filter()) :: [hit()] + + @doc "Removes a row's vector (the row itself is `AlwaysOn.remove/2`'s to remove)." + @callback delete(entry_id :: String.t()) :: :ok + + @doc "How many vectors the filter holds." + @callback count(filter()) :: non_neg_integer() + + @adapter Application.compile_env(:trinity, :db_adapter, Ecto.Adapters.SQLite3) + + @doc "The store for this build's adapter." + @spec impl() :: module() + if @adapter == Ecto.Adapters.Postgres do + def impl, do: Trinity.Memory.VectorStores.Pgvector + else + def impl, do: Trinity.Memory.VectorStores.Brute + end + + @doc "Delegates to the store in force." + @spec upsert(String.t(), [float()], String.t()) :: :ok | {:error, term()} + def upsert(id, vector, model), do: impl().upsert(id, vector, model) + + @doc "Delegates to the store in force." + @spec search([float()], pos_integer(), filter()) :: [hit()] + def search(query, k, filter), do: impl().search(query, k, filter) + + @doc "Delegates to the store in force." + @spec delete(String.t()) :: :ok + def delete(id), do: impl().delete(id) + + @doc "Delegates to the store in force." + @spec count(filter()) :: non_neg_integer() + def count(filter), do: impl().count(filter) +end diff --git a/lib/trinity/memory/vector_stores/brute.ex b/lib/trinity/memory/vector_stores/brute.ex new file mode 100644 index 0000000..96eee25 --- /dev/null +++ b/lib/trinity/memory/vector_stores/brute.ex @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStores.Brute do + @moduledoc """ + The SQLite vector store (slice 032): vectors as float32 bytes on `memories.embedding`, + a search loads the filter's rows (a persona's semantic tier, the named scopes, the + embedder in force's model) and scores them by cosine: as one matrix product on EXLA when + the NIF is loaded (the local embedder's own backend), in Elixir over decoded lists when + it is not (the hosted embedder on a machine without EXLA). Measured at G3 over 10,000 + rows (docs/perf.md): loading the rows 93 ms, the Elixir cosine 571 ms, the EXLA product + 6 ms after its first compile for that row count (102 ms). docs/02's "fine to about 10^5" + holds for the product, not for the Elixir path; hnswlib is the step after. + """ + @behaviour Trinity.Memory.VectorStore + + import Ecto.Query + + alias Trinity.Memory.{Embedder, Entry} + alias Trinity.Repo + + @impl true + def upsert(id, vector, model) when is_list(vector) do + {n, _} = + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [ + embedding: Embedder.to_binary(vector), + embedding_model: model, + embedding_dim: length(vector) + ] + ) + + if n == 1, do: :ok, else: {:error, :no_such_entry} + end + + @impl true + def search(query, k, %{persona_id: persona_id, scopes: scopes, model: model}) + when is_list(query) do + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding) + ) + |> Repo.all() + |> score(query) + |> Enum.sort_by(& &1.score, :desc) + |> Enum.take(k) + end + + @doc "Cosine of the query against every row's vector, as hits; the EXLA product when the NIF is loaded, Elixir otherwise." + @spec score([Entry.t()], Embedder.vector()) :: [Trinity.Memory.VectorStore.hit()] + def score([], _query), do: [] + + def score(rows, query) do + scores = if exla?(), do: exla_scores(rows, query), else: elixir_scores(rows, query) + Enum.zip_with(rows, scores, fn e, s -> %{id: e.id, score: s, entry: e} end) + end + + @doc "Cosines in Elixir over decoded lists (the path without EXLA); public so the suite can hold the two paths to the same numbers." + @spec elixir_scores([Entry.t()], Embedder.vector()) :: [float()] + def elixir_scores(rows, query), + do: Enum.map(rows, &Embedder.cosine(query, Embedder.from_binary(&1.embedding))) + + @doc "Cosines as one (rows x dim) . dim product on the EXLA host client; a row of another width scores 0.0, as `cosine/2` refuses it." + @spec exla_scores([Entry.t()], Embedder.vector()) :: [float()] + def exla_scores(rows, query) do + dim = length(query) + fit = Enum.filter(rows, &(byte_size(&1.embedding) == dim * 4)) + scores = if fit == [], do: %{}, else: product(fit, query, dim) + Enum.map(rows, &Map.get(scores, &1.id, 0.0)) + end + + defp product(fit, query, dim) do + m = + fit + |> Enum.map(& &1.embedding) + |> IO.iodata_to_binary() + |> Nx.from_binary(:f32, backend: EXLA.Backend) + |> Nx.reshape({length(fit), dim}) + + q = Nx.tensor(query, type: :f32, backend: EXLA.Backend) + norms = Nx.multiply(Nx.LinAlg.norm(m, axes: [1]), Nx.LinAlg.norm(q)) + + m + |> Nx.dot(q) + |> Nx.divide(Nx.select(Nx.equal(norms, 0.0), 1.0, norms)) + |> Nx.to_flat_list() + |> then(&Map.new(Enum.zip(Enum.map(fit, fn e -> e.id end), &1))) + end + + defp exla?, + do: Code.ensure_loaded?(EXLA.Backend) and Trinity.Memory.Embedders.Bumblebee.exla() == :ok + + @impl true + def delete(id) do + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: nil, embedding_model: nil, embedding_dim: nil] + ) + + :ok + end + + @impl true + def count(%{persona_id: persona_id, scopes: scopes, model: model}) do + Repo.aggregate( + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding) + ), + :count + ) + end +end diff --git a/lib/trinity/memory/vector_stores/pgvector.ex b/lib/trinity/memory/vector_stores/pgvector.ex new file mode 100644 index 0000000..6a5ef21 --- /dev/null +++ b/lib/trinity/memory/vector_stores/pgvector.ex @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Memory.VectorStores.Pgvector do + @moduledoc """ + The Postgres vector store (slice 032): the same rows, with the vector also in + `memories.embedding_vector` (pgvector, `vector(384)`), searched by cosine distance + (`<=>`) under the HNSW index over the semantic tier, with pgvector 0.8's iterative scan so a + filtered query never comes back short. Keeps `embedding`, `embedding_model` + and `embedding_dim` in step with the SQLite store so an export reads the same columns on + both. Only vectors of 384 dimensions fit the column; another embedder's go to the bytes + column alone and are searched brute force here too. + """ + @behaviour Trinity.Memory.VectorStore + + import Ecto.Query + + alias Trinity.Memory.{Embedder, Entry} + alias Trinity.Repo + + @column_dim 384 + + @impl true + def upsert(id, vector, model) when is_list(vector) do + bytes = Embedder.to_binary(vector) + + {n, _} = + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: bytes, embedding_model: model, embedding_dim: length(vector)] + ) + + if n == 1 and length(vector) == @column_dim do + Repo.query!("UPDATE memories SET embedding_vector = $1 WHERE id = $2", [ + Pgvector.new(vector), + Ecto.UUID.dump!(id) + ]) + end + + if n == 1, do: :ok, else: {:error, :no_such_entry} + end + + @impl true + def search(query, k, %{persona_id: persona_id, scopes: scopes, model: model}) + when is_list(query) do + if length(query) == @column_dim do + vec = Pgvector.new(query) + + from(e in Entry, + where: + e.persona_id == ^persona_id and e.tier == "semantic" and e.scope in ^scopes and + e.embedding_model == ^model and not is_nil(e.embedding), + order_by: fragment("embedding_vector <=> ?", ^vec), + limit: ^k, + select: {e, fragment("1 - (embedding_vector <=> ?)", ^vec)} + ) + |> all_iterative() + |> Enum.map(fn {e, score} -> %{id: e.id, score: score * 1.0, entry: e} end) + else + Trinity.Memory.VectorStores.Brute.search(query, k, %{ + persona_id: persona_id, + scopes: scopes, + model: model + }) + end + end + + # An HNSW scan hands back its `ef_search` (40) nearest index entries and the WHERE clause + # is applied after; a persona's few rows can all fall outside those candidates when the + # index holds many other entries (other personas' rows, and dead tuples from rolled-back + # inserts, which is how the postgres job saw two of three rows: run 35604770525). pgvector + # 0.8's iterative scan keeps walking the index until the LIMIT is met; `SET LOCAL` scopes + # it to this transaction (a savepoint inside the sandbox's). + defp all_iterative(query) do + {:ok, rows} = + Repo.transaction(fn -> + Repo.query!("SET LOCAL hnsw.iterative_scan = strict_order") + Repo.all(query) + end) + + rows + end + + @impl true + def delete(id) do + Repo.update_all(from(e in Entry, where: e.id == ^id), + set: [embedding: nil, embedding_model: nil, embedding_dim: nil] + ) + + Repo.query!("UPDATE memories SET embedding_vector = NULL WHERE id = $1", [Ecto.UUID.dump!(id)]) + + :ok + end + + @impl true + def count(filter), do: Trinity.Memory.VectorStores.Brute.count(filter) +end diff --git a/lib/trinity/receipts/chain_writer.ex b/lib/trinity/receipts/chain_writer.ex index e1e5b32..8ba364c 100644 --- a/lib/trinity/receipts/chain_writer.ex +++ b/lib/trinity/receipts/chain_writer.ex @@ -226,6 +226,9 @@ defmodule Trinity.Receipts.ChainWriter do if Receipt.signed?(kind) do case KeyCustody.sign(bytes) do {:ok, sig} -> + # fix(s024) at slice 032: a signer that signs again clears the alarm, as Alarm's + # doc has said since 024; before this nothing did. + if Trinity.Receipts.Alarm.set?(), do: Trinity.Receipts.Alarm.clear() {:ok, sig} {:error, reason} -> @@ -322,6 +325,7 @@ defmodule Trinity.Receipts.ChainWriter do defp sign_checkpoint(bytes) do case KeyCustody.sign(bytes) do {:ok, signature} -> + if Trinity.Receipts.Alarm.set?(), do: Trinity.Receipts.Alarm.clear() {:ok, signature} {:error, why} -> diff --git a/lib/trinity/repo/postgrex_types.ex b/lib/trinity/repo/postgrex_types.ex new file mode 100644 index 0000000..96a0b3a --- /dev/null +++ b/lib/trinity/repo/postgrex_types.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# Slice 032: the Postgres build's Postgrex types with pgvector's `vector` beside the +# defaults, so `memories.embedding_vector` can be bound and read. Defined only when the +# Postgres adapter is compiled in (`TRINITY_DB=postgres`; postgrex is optional and absent +# from the desktop build); `config :trinity, Trinity.Repo, types: Trinity.Repo.PostgrexTypes` +# names it in the same branch of config/runtime.exs and config/test.exs. +if Application.compile_env(:trinity, :db_adapter, Ecto.Adapters.SQLite3) == Ecto.Adapters.Postgres do + Postgrex.Types.define( + Trinity.Repo.PostgrexTypes, + Pgvector.extensions() ++ Ecto.Adapters.Postgres.extensions(), + [] + ) +end diff --git a/lib/trinity/sessions.ex b/lib/trinity/sessions.ex index 37e6ab6..8f90c3b 100644 --- a/lib/trinity/sessions.ex +++ b/lib/trinity/sessions.ex @@ -157,6 +157,10 @@ defmodule Trinity.Sessions do do: Store.update_session(session, %{project_root: expanded}) end + @doc "A message by id (slice 032: the memory page's provenance link)." + @spec get_message(String.t()) :: Message.t() | nil + def get_message(id), do: Store.get_message(id) + @doc "The number of messages in a session." @spec message_count(session_id()) :: non_neg_integer() def message_count(session_id), do: Store.message_count(session_id) diff --git a/lib/trinity/sessions/prompt.ex b/lib/trinity/sessions/prompt.ex index d84cffd..644714f 100644 --- a/lib/trinity/sessions/prompt.ex +++ b/lib/trinity/sessions/prompt.ex @@ -29,7 +29,15 @@ defmodule Trinity.Sessions.Prompt do # plus 300 for the skills index 040 adds). @default_budgets [stable: 800, context: 5_800, volatile: 2_800] - @type truncation :: %{tier: :stable | :context | :volatile, dropped_tokens: pos_integer()} + @type truncation :: %{ + tier: :stable | :context | :volatile | :recall, + dropped_tokens: pos_integer() + } + + # Slice 032: the recall block's own cap inside the volatile tier, measured against the + # volatile budget of 030 (2,800): eight one-line hits of 300 characters are about 800 tokens + # under the estimator; 600 keeps the always-in-mind block and the facts whole. + @default_recall_tokens 600 @doc "The request for the next model call; `tools` is the declared surface (slice 020), none by default." @spec build(SessionRow.t(), Persona.t() | nil, [Message.t()], [Request.tool()], keyword()) :: @@ -43,7 +51,9 @@ defmodule Trinity.Sessions.Prompt do The request and the truncations the tier budgets forced. `opts`: `memory:` (the snapshot block, `""` when none), `now:` (the time the volatile tier states; `DateTime.utc_now/0` by default), `context:` (the context tier's text: the AGENTS.md block from slice 033, the - skills index from 040; `""` when none). + skills index from 040; `""` when none), `recall:` (slice 032's "Relevant memories" block, + cut at `config :trinity, :memory, recall_tokens:` before it joins the volatile tier, the + cut reported as the `:recall` tier; `""` when none). """ @spec build_with_report( SessionRow.t(), @@ -57,6 +67,12 @@ defmodule Trinity.Sessions.Prompt do {compaction, rows} = fold_compaction(history) budgets = Keyword.merge(@default_budgets, Application.get_env(:trinity, :prompt_budgets, [])) + {recall, recall_truncations} = + case cut(Keyword.get(opts, :recall, ""), recall_tokens()) do + {kept, 0} -> {kept, []} + {kept, dropped} -> {kept, [%{tier: :recall, dropped_tokens: dropped}]} + end + tiers = [ {:stable, system(persona) <> "\n\n" <> @untrusted_rule}, {:context, Keyword.get(opts, :context, "")}, @@ -64,12 +80,13 @@ defmodule Trinity.Sessions.Prompt do volatile( session, Keyword.get(opts, :memory, ""), + recall, Keyword.get(opts, :now) || DateTime.utc_now() )} ] {texts, truncations} = - Enum.map_reduce(tiers, [], fn {tier, text}, acc -> + Enum.map_reduce(tiers, recall_truncations, fn {tier, text}, acc -> case cut(text, Keyword.fetch!(budgets, tier)) do {kept, 0} -> {kept, acc} {kept, dropped} -> {kept, acc ++ [%{tier: tier, dropped_tokens: dropped}]} @@ -95,13 +112,23 @@ defmodule Trinity.Sessions.Prompt do def budgets, do: Keyword.merge(@default_budgets, Application.get_env(:trinity, :prompt_budgets, [])) - # The volatile tier: the memory block, the time, the session's facts. - defp volatile(session, memory, now) do + @doc "The recall block's cap in tokens." + @spec recall_tokens() :: pos_integer() + def recall_tokens, + do: + Keyword.get( + Application.get_env(:trinity, :memory, []), + :recall_tokens, + @default_recall_tokens + ) + + # The volatile tier: the memory block, the recall block, the time, the session's facts. + defp volatile(session, memory, recall, now) do facts = "The time now is #{DateTime.to_iso8601(DateTime.truncate(now, :second))} (UTC)." <> title(session) - if memory == "", do: facts, else: memory <> "\n\n" <> facts + [memory, recall, facts] |> Enum.reject(&(&1 == "")) |> Enum.join("\n\n") end defp title(%SessionRow{title: t}) when is_binary(t) and t != "", diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index 45f1100..af42213 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -357,8 +357,16 @@ defmodule Trinity.Sessions.Session do # Slice 033: the project's AGENTS.md, read now, so a change is in this turn (live reload). context = Trinity.Context.AgentsMd.render(session.project_root, session.project_root) + # Slice 032: what the semantic tier and past conversations hold about the latest user + # message, fused and capped; "" when there is no persona or nothing relevant. + recall = recall_block(session, history) + {request, truncations} = - Prompt.build_with_report(session, persona, history, tools, memory: memory, context: context) + Prompt.build_with_report(session, persona, history, tools, + memory: memory, + context: context, + recall: recall + ) Enum.each(truncations, &truncation_receipt(id, &1)) {session, persona, history, request} @@ -379,6 +387,20 @@ defmodule Trinity.Sessions.Session do }) end + # Slice 032: the retriever runs on the latest user message, over the session's scope chain. + defp recall_block(%{persona_id: persona_id, id: id}, history) when is_binary(persona_id) do + case Enum.reverse(history) |> Enum.find(&(&1.role == "user")) do + nil -> + "" + + %{content: query} -> + Trinity.Memory.Retriever.relevant(persona_id, id, query) + |> Trinity.Memory.Retriever.render() + end + end + + defp recall_block(_, _), do: "" + # Slice 030: the always-on block for this session's chain, frozen in state. defp memory_snapshot(%{id: id, persona_id: persona_id}) when is_binary(persona_id), do: Trinity.Memory.AlwaysOn.snapshot(persona_id, id) @@ -590,7 +612,9 @@ defmodule Trinity.Sessions.Session do meta = if turn.finish, do: Map.put(meta, "finish", Atom.to_string(turn.finish)), else: meta meta = Map.put(meta, "tool_surface", turn.surface) - content = if turn.text == "", do: "(no text)", else: turn.text + # Whitespace-only text is blank to `validate_required` (fix(s012) at slice 032: a + # model's lone "\n" before its tool calls lost the assistant row and its calls). + content = if String.trim(turn.text) == "", do: "(no text)", else: turn.text result = case turn.draft_id && Store.get_message(turn.draft_id) do @@ -634,10 +658,25 @@ defmodule Trinity.Sessions.Session do _ -> data = persist_final(data, %{}) + observe_turn(data) {:next_state, :idle, %{data | turn: nil}} end end + # Slice 032: the completed turn (from its user message on) goes to the memory observer, + # which runs under its own supervisor; the session is idle at once and never waits on it. + defp observe_turn(%State{id: id, session: session}) do + row = Store.get_session(id) || session + history = Trinity.Sessions.history(id, limit: 60) + last_user = history |> Enum.reverse() |> Enum.find_index(&(&1.role == "user")) + turn = if last_user, do: Enum.take(history, -(last_user + 1)), else: history + + Trinity.Memory.Observer.observe( + %{session_id: id, persona_id: row.persona_id, model: row.model}, + Enum.map(turn, &%{id: &1.id, role: &1.role, content: &1.content}) + ) + end + # After the tool rows: the next model call, or the cap. defp next_turn(%State{turn: turn} = data) do turn = %{turn | pending: [], held: [], turns: turn.turns + 1} diff --git a/lib/trinity/smoke.ex b/lib/trinity/smoke.ex index e9ffc57..b5ddd92 100644 --- a/lib/trinity/smoke.ex +++ b/lib/trinity/smoke.ex @@ -44,9 +44,17 @@ defmodule Trinity.Smoke do @spec argv() :: [String.t()] def argv, do: Enum.map(:init.get_plain_arguments(), &to_string/1) - @doc "Whether `#{@flag}` was passed." + @doc """ + Whether the smoke run was asked for: `#{@flag}` among the arguments, or `TRINITY_SMOKE=1` in + the environment. The variable is the form the package workflow uses since slice 032: + `Kernel.CLI` reads the same plain arguments once the application has started and treats + `#{@flag}` as a file to run ("No file named --smoke", exit 1), and the Task that prints the + lines and halts wins that race only sometimes (package run 35608084951, macOS: exit 1 + between the fourth line and the fifth). An environment variable is nothing for the CLI to + read. + """ @spec requested?([String.t()]) :: boolean() - def requested?(args), do: @flag in args + def requested?(args), do: @flag in args or System.get_env("TRINITY_SMOKE") == "1" @doc """ The line printed for the caller to parse. One key=value pair, no prose around it, so a shell @@ -74,18 +82,77 @@ defmodule Trinity.Smoke do end end + @doc """ + The third line (slice 032): whether the EXLA NIF loaded in this binary and ran one + operation. Informative, not binding: a bundle whose XLA library does not load (Burrito's + musl ERTS against a glibc `.so`, NOTES finding 13's shape) still boots with the semantic + tier off, and AC7 asks that the failure be recorded by name. `ok` or `failed:`. + """ + @spec exla_line() :: String.t() + def exla_line do + if Code.ensure_loaded?(EXLA) and Trinity.Memory.Embedders.Bumblebee.exla() == :ok do + try do + t = Nx.tensor([1.0, 2.0], backend: EXLA.Backend) + [3.0] = Nx.to_flat_list(Nx.sum(t)) + "TRINITY_SMOKE_EXLA=ok" + rescue + e -> "TRINITY_SMOKE_EXLA=failed:#{inspect(Exception.message(e) |> String.slice(0, 200))}" + catch + kind, reason -> + "TRINITY_SMOKE_EXLA=failed:#{inspect({kind, reason}) |> String.slice(0, 200)}" + end + else + "TRINITY_SMOKE_EXLA=failed:#{inspect(Trinity.Memory.Embedders.Bumblebee.exla()) |> String.slice(0, 200)}" + end + end + + @doc """ + The fourth line (slice 032, AC7): a fake-vector search inside this binary through the + vector store in force, on the database the binary opened, rolled back. Three rows of the + suite's deterministic vectors, the nearest expected first; `ok:` or + `failed:`. Binding: exit 4 when it fails. The tier's own status follows as the + fifth line, informative (`on`, or the reason it is off on this machine). + """ + @spec vec_line() :: String.t() + def vec_line do + case Trinity.Memory.Semantic.smoke() do + {:ok, store} -> "TRINITY_SMOKE_VEC=ok:#{inspect(store)}" + {:error, reason} -> "TRINITY_SMOKE_VEC=failed:#{inspect(reason) |> String.slice(0, 200)}" + end + end + + @doc "The fifth line: the semantic tier's status in this binary, informative." + @spec semantic_line() :: String.t() + def semantic_line do + case Trinity.Memory.Semantic.status() do + :on -> "TRINITY_SMOKE_SEMANTIC=on" + {:off, reason} -> "TRINITY_SMOKE_SEMANTIC=off:#{inspect(reason) |> String.slice(0, 200)}" + end + end + @doc """ Runs the smoke check: report the listening port, then stop the OS process. `say` and `halt` are injected so the whole path is exercisable from a test without ending the test runner's own OS process. """ - @spec run(say_fun(), halt_fun(), String.t()) :: any() - def run(say \\ &IO.puts/1, halt \\ &System.halt/1, markdown \\ markdown_line()) do + @spec run(say_fun(), halt_fun(), String.t(), [String.t()]) :: any() + def run(say \\ &IO.puts/1, halt \\ &System.halt/1, markdown \\ markdown_line(), rest \\ nil) do {:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http) say.(port_line(port)) say.(markdown) - halt.(if markdown == "TRINITY_SMOKE_MARKDOWN=ok", do: 0, else: 3) + [exla, vec, semantic] = rest || [exla_line(), vec_line(), semantic_line()] + say.(exla) + say.(vec) + say.(semantic) + + halt.( + cond do + markdown != "TRINITY_SMOKE_MARKDOWN=ok" -> 3 + not String.starts_with?(vec, "TRINITY_SMOKE_VEC=ok:") -> 4 + true -> 0 + end + ) end @doc """ @@ -109,9 +176,46 @@ defmodule Trinity.Smoke do def children(args) do if requested?(args) do markdown = markdown_line() - [{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown) end}] + # The 032 lines were computed by `probe/1`, a child placed after the Repo, the memory + # supervisor and the sessions (package run 35600216451: computed here, before the + # tree, the vector check found no Repo); the Task only reads them. + [{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown, probed()) end}] else [] end end + + @doc """ + The child that computes the 032 lines (slice 032): placed in `Trinity.Application` just + before the endpoint, so the Repo, the memory supervisor and the sessions are up. Its + `start_link/1` does the work synchronously and answers `:ignore`, so the supervisor waits + for it and starts no process; `children/1`'s Task reads the result. + """ + @spec probe([String.t()]) :: [Supervisor.child_spec()] + def probe(args) do + if requested?(args), do: [Trinity.Smoke.Probe], else: [] + end + + @doc false + @spec probed() :: [String.t()] + def probed do + :persistent_term.get({__MODULE__, :probed}, [ + "TRINITY_SMOKE_EXLA=failed:not_probed", + "TRINITY_SMOKE_VEC=failed:not_probed", + "TRINITY_SMOKE_SEMANTIC=off:not_probed" + ]) + end + + defmodule Probe do + @moduledoc false + use Boundary, top_level?: true, deps: [Trinity, Trinity.Smoke] + + def child_spec(_), do: %{id: __MODULE__, start: {__MODULE__, :start_link, []}} + + def start_link do + lines = [Trinity.Smoke.exla_line(), Trinity.Smoke.vec_line(), Trinity.Smoke.semantic_line()] + :persistent_term.put({Trinity.Smoke, :probed}, lines) + :ignore + end + end end diff --git a/lib/trinity/tools/recall.ex b/lib/trinity/tools/recall.ex new file mode 100644 index 0000000..886eda9 --- /dev/null +++ b/lib/trinity/tools/recall.ex @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Tools.Recall do + @moduledoc """ + `recall`: hybrid recall over the semantic tier and past conversations (slice 032, + `Trinity.Memory.Retriever`). A read: risk `:read`, effect `:none`, so it runs without + asking and leaves a query receipt. The hits are the person's own memories and history and + come back as text the model reads; they are wrapped as untrusted all the same, as + `session_search`'s are (docs/07: a tool result re-entering the prompt is data). When the + semantic tier is off the answer says so and carries the full-text half alone. + """ + @behaviour Trinity.Tools.Tool + + alias Trinity.Memory.{Retriever, Semantic} + alias Trinity.Tools.{Context, Untrusted} + + @default_k 8 + @max_k 20 + + @impl true + def name, do: "recall" + + @impl true + def description, + do: + "Recalls what is relevant to a question from long-term memory and past conversations: memories by meaning, messages by their words, fused and recent first. Returns at most `k` hits, each as `memory Β· when Β· text` or `session Β· when Β· role: …snippet…`. Use it before answering anything that depends on what this person told you in the past." + + @impl true + def schema, + do: %{ + "type" => "object", + "properties" => %{ + "query" => %{ + "type" => "string", + "description" => "What to recall, as a question or a phrase" + }, + "k" => %{ + "type" => "integer", + "minimum" => 1, + "maximum" => @max_k, + "description" => "At most this many hits (default #{@default_k})" + } + }, + "required" => ["query"], + "additionalProperties" => false + } + + @impl true + def risk, do: :read + + @impl true + def effect, do: :none + + @impl true + def execute(%{"query" => query} = args, %Context{session_id: session_id, persona: persona}) do + k = args |> Map.get("k", @default_k) |> min(@max_k) + status = Semantic.status() + + hits = + case persona do + %{id: persona_id} -> Retriever.relevant(persona_id, session_id, query, k: k) + _ -> [] + end + + text = + case {hits, status} do + {[], _} -> "Nothing recalled for #{inspect(query)}." + {hits, _} -> Enum.map_join(hits, "\n", &line/1) + end + + text = + if status == :on, + do: text, + else: "(#{Semantic.describe(status)}; full-text hits only)\n" <> text + + meta = %{ + "query" => query, + "hits" => length(hits), + "k" => k, + "semantic" => status == :on, + "memory_hits" => Enum.count(hits, &(&1.kind == :memory)) + } + + {:ok, Untrusted.result(text, tool: name(), source_ref: "recall:" <> query, meta: meta)} + end + + defp line(%{kind: :memory, at: at, text: text, ref: ref}), + do: "memory #{ref.key} Β· #{Calendar.strftime(at, "%Y-%m-%d")} Β· #{text}" + + defp line(%{kind: :message, at: at, text: text, ref: ref}) do + title = ref.session_title || "untitled" + + "#{title} (#{ref.session_id}) Β· #{Calendar.strftime(at, "%Y-%m-%d %H:%M")} Β· #{ref.role}: #{text}" + end +end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index a950ba5..b17cdf5 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -141,10 +141,10 @@ defmodule Trinity.Versions do }, %{ name: "pgvector", - pin: "optional, ~> 0.3", + pin: "~> 0.4.1", lock: "pgvector", note: - "Vectors on the Postgres path. Not yet a dependency; Slice 032 decides. Split from the postgrex row at Slice 010." + "Vectors on the Postgres path: `memories.embedding_vector vector(384)` under an HNSW cosine index, searched by `Trinity.Memory.VectorStores.Pgvector` (Slice 032). The postgres job runs on the `pgvector/pgvector:pg17` image. Split from the postgrex row at Slice 010." }, %{ name: "oban", @@ -193,30 +193,39 @@ defmodule Trinity.Versions do @memory [ %{ - name: "nx, exla", - pin: "latest stable", - lock: nil, + name: "nx", + pin: "~> 0.13.1", + lock: "nx", + note: + "Tensors for the local embedder (Slice 032). The 0.13 line, not 1.0.0 (2026-09-10): bumblebee 0.7.1 accepts `~> 0.12 or ~> 0.13` and has no release for 1.0. Measured at Slice 032: docs/perf.md." + }, + %{ + name: "exla", + pin: "~> 0.13.1", + lock: "exla", note: - "Local embeddings. EXLA binary size matters for desktop: measure in 032. Two packages, so no single lock key." + "The XLA backend the serving compiles to (Slice 032); precompiled for x86_64 and aarch64 Linux and macOS, none for Windows (not declared on a Windows host; the tier is off there). Declared `runtime: false` and carried by the release in `:load` mode, started on demand by `Trinity.Memory.Embedders.Bumblebee.exla/0`: its NIF does not load in Burrito's Linux ERTS (NOTES finding 2). The XLA shared library is 463 MB on disk: docs/perf.md. Pulls `xla` 0.10.0 and `fine`." }, %{ name: "bumblebee", - pin: "~> 0.7", + pin: "~> 0.7.1", lock: "bumblebee", - note: "`all-MiniLM-L6-v2` embeddings; Whisper later. Added at Slice 032." + note: + "`all-MiniLM-L6-v2` embeddings through `Trinity.Memory.Embedders.Bumblebee` (Slice 032); Whisper later. Pulls `axon` 0.8.1 and `tokenizers` 0.5.1 (a precompiled Rust NIF)." }, %{ name: "sqlite_vec", - pin: "~> 0.1", - lock: "sqlite_vec", + pin: "not used (decided at Slice 032, 2026-09-21)", + lock: nil, note: - "Vectors in SQLite. Verify the loadable extension works inside the Burrito bundle (Slice 032). ⚠️ Pre-1.0, no release in roughly 22 months, 6,938 downloads all-time. R11's trigger already fires. Decide the fallback before Slice 032 starts." + "Vectors in SQLite. Its only release (0.1.0, 2024-11-19) requires `nx ~> 0.9` and cannot sit in a tree with nx 0.13; R11's trigger fired and the owner chose brute force in Elixir over the persona's semantic rows (`Trinity.Memory.VectorStores.Brute`, docs/02: fine to about 10^5) with pgvector on the Postgres path. The row stays so the decision is visible where a reader would look for the package." }, %{ name: "hnswlib", - pin: "~> 0.1.7", + pin: "~> 0.1.10", lock: "hnswlib", - note: "⚠️ Pre-1.0. Optional accelerator; not on the critical path." + note: + "⚠️ Pre-1.0 (0.1.10, 2026-09-17, active). The scale option after brute force; not yet a dependency, the slice that measures brute force past 10^5 rows adds it." } ] diff --git a/lib/trinity_web/live/memory_live.ex b/lib/trinity_web/live/memory_live.ex index ae1fe5d..d2b9adf 100644 --- a/lib/trinity_web/live/memory_live.ex +++ b/lib/trinity_web/live/memory_live.ex @@ -8,12 +8,21 @@ defmodule TrinityWeb.MemoryLive do with `by: "ui"`, so it is in the change log like the tool's; a proposal is applied or rejected through `Trinity.Memory.Consolidator`. Running sessions keep their snapshot until their next start or refresh. + + `/memory?tab=semantic` (slice 032): the semantic tier. The tier's status line (on, or why + it is unavailable), the model download as an explicit action with progress when the local + model is missing, a search box over `Trinity.Memory.Retriever` (fused hits with their + scores and which list found them), and every semantic memory with its provenance link to + the message it came from, deletable or pinnable (pin: promoted to `always_on`, logged). """ use TrinityWeb, :live_view - alias Trinity.Memory.{AlwaysOn, Budget, Consolidator} + alias Trinity.Memory.{AlwaysOn, Budget, Consolidator, Embedders, Retriever, Semantic} alias Trinity.Personas + @tabs ~w(always_on semantic) + @model_bytes 91_359_935 + @impl true def mount(params, _session, socket) do personas = Personas.list() @@ -21,8 +30,23 @@ defmodule TrinityWeb.MemoryLive do {:ok, socket - |> assign(page_title: gettext("Memory"), personas: personas, persona: persona, editing: nil) - |> load()} + |> assign( + page_title: gettext("Memory"), + personas: personas, + persona: persona, + editing: nil, + tab: "always_on", + query: "", + hits: nil, + download: nil + )} + end + + # The tab is the URL's; every patch reloads, so a tab shows what the other's writes left. + @impl true + def handle_params(params, _uri, socket) do + tab = if params["tab"] in @tabs, do: params["tab"], else: "always_on" + {:noreply, socket |> assign(tab: tab) |> load()} end defp load(%{assigns: %{persona: persona}} = socket) do @@ -30,16 +54,74 @@ defmodule TrinityWeb.MemoryLive do entries: AlwaysOn.all(persona.id), budget: Budget.status(persona.id), proposals: Consolidator.pending(persona.id), - changes: AlwaysOn.changes(persona.id, limit: 20) + changes: AlwaysOn.changes(persona.id, limit: 20), + semantic: Semantic.all(persona.id), + semantic_count: Semantic.count(persona.id), + status: Semantic.status() ) end @impl true def handle_event("pick_persona", %{"persona_id" => id}, socket) do persona = Enum.find(socket.assigns.personas, &(&1.id == id)) || socket.assigns.persona - {:noreply, socket |> assign(persona: persona, editing: nil) |> load()} + {:noreply, socket |> assign(persona: persona, editing: nil, hits: nil) |> load()} end + def handle_event( + "semantic_search", + %{"query" => query}, + %{assigns: %{persona: persona}} = socket + ) do + query = String.trim(query) + + hits = + if query == "", + do: nil, + else: + Retriever.relevant(persona.id, nil, query, k: 20, touch: false) |> Enum.map(&decorate/1) + + {:noreply, assign(socket, query: query, hits: hits)} + end + + def handle_event("semantic_delete", %{"id" => id}, socket) do + with %{} = entry <- Enum.find(socket.assigns.semantic, &(&1.id == id)), + {:ok, _} <- Semantic.remove(entry, by: "ui") do + {:noreply, socket |> assign(hits: nil) |> load()} + else + _ -> {:noreply, put_flash(socket, :error, gettext("Not deleted."))} + end + end + + def handle_event("semantic_pin", %{"id" => id}, socket) do + with %{} = entry <- Enum.find(socket.assigns.semantic, &(&1.id == id)), + {:ok, _} <- Semantic.pin(entry, by: "ui") do + {:noreply, socket |> assign(hits: nil) |> load()} + else + {:error, :exists} -> + {:noreply, put_flash(socket, :error, gettext("That key is already always in mind."))} + + _ -> + {:noreply, put_flash(socket, :error, gettext("Not pinned."))} + end + end + + # The one network egress on this page, started by a click and nothing else: the weights + # and tokenizer into the model cache, under the memory task supervisor; the page ticks the + # cache's size against the model's while it runs. + def handle_event("download_model", _params, %{assigns: %{download: nil}} = socket) do + me = self() + + {:ok, _} = + Task.Supervisor.start_child(Trinity.Memory.TaskSupervisor, fn -> + send(me, {:download_done, Embedders.Bumblebee.download()}) + end) + + Process.send_after(self(), :download_tick, 1_000) + {:noreply, assign(socket, download: %{bytes: cache_bytes(), of: @model_bytes})} + end + + def handle_event("download_model", _params, socket), do: {:noreply, socket} + def handle_event( "add", %{"tier" => tier, "key" => key, "body" => body}, @@ -109,6 +191,69 @@ defmodule TrinityWeb.MemoryLive do def handle_event("cancel", _params, socket), do: {:noreply, socket} + @impl true + def handle_info(:download_tick, %{assigns: %{download: %{} = d}} = socket) do + Process.send_after(self(), :download_tick, 1_000) + {:noreply, assign(socket, download: %{d | bytes: cache_bytes()})} + end + + def handle_info(:download_tick, socket), do: {:noreply, socket} + + def handle_info({:download_done, :ok}, socket) do + {:noreply, + socket + |> assign(download: nil) + |> put_flash(:info, gettext("The model is downloaded; semantic recall is on.")) + |> load()} + end + + def handle_info({:download_done, {:error, reason}}, socket) do + {:noreply, + socket + |> assign(download: nil) + |> put_flash(:error, gettext("The download failed: %{r}", r: inspect(reason))) + |> load()} + end + + defp cache_bytes do + dir = Embedders.Bumblebee.cache_dir() + + if File.dir?(dir) do + dir + |> Path.join("**") + |> Path.wildcard(match_dot: true) + |> Enum.reduce(0, &(file_size(&1) + &2)) + else + 0 + end + end + + defp file_size(path) do + case File.stat(path) do + {:ok, %{type: :regular, size: size}} -> size + _ -> 0 + end + end + + # A hit with what the page shows: the memory's source message's session, for the link. + defp decorate(%{kind: :memory, ref: %{source_message_id: mid}} = hit) when is_binary(mid) do + case Trinity.Sessions.get_message(mid) do + %{session_id: sid} -> Map.put(hit, :source_session_id, sid) + _ -> hit + end + end + + defp decorate(hit), do: hit + + defp source_session(%{source_message_id: nil}), do: nil + + defp source_session(%{source_message_id: mid}) do + case Trinity.Sessions.get_message(mid) do + %{session_id: sid} -> sid + _ -> nil + end + end + @impl true def render(assigns) do ~H""" @@ -141,7 +286,146 @@ defmodule TrinityWeb.MemoryLive do phx-hook="Shortcuts" class="mx-auto flex h-full max-w-4xl flex-col gap-6 overflow-y-auto px-4 py-6" > -
+ + +
+

+ {Semantic.describe(@status)}. + {gettext("Full-text search still works.")} +

+
+ + + {gettext("downloading: %{got} of %{of} MB", + got: div(@download.bytes, 1_000_000), + of: div(@download.of, 1_000_000) + )} + + +
+ + +
    +
  • {gettext("Nothing recalled.")}
  • +
  • + {Float.round(h.score, 4)} + {Enum.map_join( + h.found_by, + "+", + &Atom.to_string/1 + )} + {h.text} + <.link + :if={h.kind == :message} + navigate={~p"/s/#{h.ref.session_id}"} + class="link text-meta" + > + {h.ref.session_title || gettext("untitled")} + + <.link + :if={h[:source_session_id]} + navigate={~p"/s/#{h.source_session_id}"} + class="link text-meta" + > + {gettext("source")} + +
  • +
+ +

{gettext("Remembered")}

+

+ {gettext("Nothing in the semantic tier yet; the observer adds to it after each turn.")} +

+
    +
  • + {short_scope(e.scope)} + {Calendar.strftime( + e.inserted_at, + "%Y-%m-%d" + )} + {e.body} + {e.confidence} + <.link + :if={source_session(e)} + navigate={~p"/s/#{source_session(e)}"} + class="link text-meta" + > + {gettext("source")} + + + +
  • +
+
+ +

{gettext("Consolidation waiting for you")}

-
+

{if tier == "profile", do: gettext("About the person"), else: gettext("Always in mind")}

@@ -218,7 +506,12 @@ defmodule TrinityWeb.MemoryLive do
-
+