Compare commits

..

5 Commits

22 changed files with 351 additions and 39 deletions

View File

@@ -16,7 +16,12 @@
"mcp__Claude_in_Chrome__computer", "mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__browser_batch", "mcp__Claude_in_Chrome__browser_batch",
"mcp__Claude_in_Chrome__javascript_tool", "mcp__Claude_in_Chrome__javascript_tool",
"Bash(allium check *)" "Bash(allium check *)",
"Bash(mix deps.get)",
"Bash(allium --help)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)"
] ]
} }
} }

3
.gitignore vendored
View File

@@ -10,4 +10,7 @@
/priv/data/*.db /priv/data/*.db
/priv/data/*.db-shm /priv/data/*.db-shm
/priv/data/*.db-wal /priv/data/*.db-wal
# Embeddings index artifacts are per-project runtime caches, never committed.
*.usearch
*.usearch.meta.json
*.eztmp/ *.eztmp/

View File

@@ -25,8 +25,9 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-13 | ~~Git sidebar shows only "Working tree" placeholder~~ | sidebar_views.allium:651-770 | `git_view/1` now builds a full `layout: "git"` view from `BDS.Git` (repository/remote_state/status/history); `SidebarComponents` renders active + not_a_repo states | **Resolved:** `git_view/1` in sidebar.ex assembles branch/upstream/ahead/behind, status files, paginated history (20/page); `render_git_sidebar` renders branch header, sync legend, fetch/pull/push/prune-lfs buttons, commit form, clickable status files (open git_diff), history entries; shell_live wires `git_commit` (closes git_diff tabs), `git_fetch`/`git_pull`/`git_push`/`git_prune_lfs`, `git_initialize`; `BDS.Git.history` enriched with author/date, `BDS.Git.set_remote/2` added; i18n for de/fr/it/es; 3 shell tests + git author/date assertions added | | A1-13 | ~~Git sidebar shows only "Working tree" placeholder~~ | sidebar_views.allium:651-770 | `git_view/1` now builds a full `layout: "git"` view from `BDS.Git` (repository/remote_state/status/history); `SidebarComponents` renders active + not_a_repo states | **Resolved:** `git_view/1` in sidebar.ex assembles branch/upstream/ahead/behind, status files, paginated history (20/page); `render_git_sidebar` renders branch header, sync legend, fetch/pull/push/prune-lfs buttons, commit form, clickable status files (open git_diff), history entries; shell_live wires `git_commit` (closes git_diff tabs), `git_fetch`/`git_pull`/`git_push`/`git_prune_lfs`, `git_initialize`; `BDS.Git.history` enriched with author/date, `BDS.Git.set_remote/2` added; i18n for de/fr/it/es; 3 shell tests + git author/date assertions added |
| A1-14 | ~~Embedding uses TF-IDF hash projection instead of real neural model~~ | embedding.allium:44-53, invariants RealNeuralModel/ModelCaching/VectorCacheInDb | `Backends.Neural` runs `intfloat/multilingual-e5-small` (e5 weights behind the Xenova id) via Bumblebee+EXLA | **Resolved (core):** added bumblebee/nx/exla deps; `Backends.Neural` is a lazily-loaded GenServer that builds the Bumblebee text-embedding serving on first request (`"query: "` prefix + mean pooling + L2 norm), downloads+caches the model under the app data dir (ModelCaching), and is wired into the supervision tree when configured; vectors now persisted as packed little-endian Float32 BLOB (384×4=1536 bytes) instead of JSON text (VectorCacheInDb) with migration recreating `embedding_keys.vector` as BLOB; `InApp` demoted to documented offline/test stub; test config uses the stub so the suite stays offline; spec EmbeddingModel clarified (Xenova id ↔ intfloat weights via Bumblebee); batched inference via optional `embed_many/2` backend callback (configurable `batch_size`/`sequence_length`; rebuild/index/repair embed in chunks instead of one post at a time) + `NativeAcceleratedExecution` invariant added to spec; 4 tests added (BLOB round-trip, batched-rebuild, Neural model_info/behaviour). **Deferred:** A1-14b USearch HNSW index, A1-14c Apple GPU (EMLX). | | A1-14 | ~~Embedding uses TF-IDF hash projection instead of real neural model~~ | embedding.allium:44-53, invariants RealNeuralModel/ModelCaching/VectorCacheInDb | `Backends.Neural` runs `intfloat/multilingual-e5-small` (e5 weights behind the Xenova id) via Bumblebee+EXLA | **Resolved (core):** added bumblebee/nx/exla deps; `Backends.Neural` is a lazily-loaded GenServer that builds the Bumblebee text-embedding serving on first request (`"query: "` prefix + mean pooling + L2 norm), downloads+caches the model under the app data dir (ModelCaching), and is wired into the supervision tree when configured; vectors now persisted as packed little-endian Float32 BLOB (384×4=1536 bytes) instead of JSON text (VectorCacheInDb) with migration recreating `embedding_keys.vector` as BLOB; `InApp` demoted to documented offline/test stub; test config uses the stub so the suite stays offline; spec EmbeddingModel clarified (Xenova id ↔ intfloat weights via Bumblebee); batched inference via optional `embed_many/2` backend callback (configurable `batch_size`/`sequence_length`; rebuild/index/repair embed in chunks instead of one post at a time) + `NativeAcceleratedExecution` invariant added to spec; 4 tests added (BLOB round-trip, batched-rebuild, Neural model_info/behaviour). **Deferred:** A1-14b USearch HNSW index, A1-14c Apple GPU (EMLX). |
| A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). | | A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). |
| A1-14c | Embedding model runs on CPU only; no Apple GPU acceleration | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` uses Bumblebee+EXLA; on Apple Silicon XLA has no Metal backend so inference is native CPU (batched). Apple GPU/Neural Engine unused | Fix code: spike an EMLX (Apple MLX) Nx backend so the model executes on the Apple Silicon GPU; gate by platform/availability with EXLA-CPU fallback; verify Bumblebee serving + defn compiler compatibility and benchmark vs CPU batching | | A1-14c | ~~Embedding model runs on CPU only; no Apple GPU acceleration~~ | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` now selects the defn compiler at serving-build time: Apple GPU via EMLX (MLX/Metal) on arm64 macOS, EXLA-CPU elsewhere | **Resolved:** added `{:emlx, "~> 0.2.0"}` dep (ships precompiled MLX binaries; EMLX 0.2.0 implements both `EMLX.Backend` and the `Nx.Defn.Compiler` behaviour, GPU-default); `Backends.Neural` gained a pure `select_accelerator/3` policy (`:auto` prefers EMLX only when available **and** on Apple Silicon; explicit `:emlx`/`:exla` honoured; forced `:emlx` degrades to EXLA when unavailable so misconfigured hosts still run), `current_accelerator/0`, and `defn_options/1`; `build_serving` places params on `{EMLX.Backend, device: :gpu}` and compiles with `EMLX` for the EMLX path, keeps `EXLA` otherwise; new `accelerator: :auto` config key; spec `NativeAcceleratedExecution` + `EmbeddingModel` updated; PLT app added; 7 tests added (offline — test config still uses the InApp stub). |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior | | A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
| A1-16 | Public project content + data_path discovery not compliant with storage-location spec | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` (newly added) | **Private side done:** `Projects.project_cache_root/0` now falls back to the OS private app dir (`:filename.basedir(:user_config, "bds")` → macOS `~/Library/Application Support/bds`) instead of `priv/data`, so the embeddings index no longer lands in the repo. **Still non-compliant (public side):** `project_data_dir/0` (projects.ex:97-99) falls back to `priv/data/projects/<id>` when `data_path` is nil, so the default project's *public* content (posts, media, templates, scripts, `meta/`, generated `html/`) is written into the application repo; there is no discovery of `data_path` from the `meta/project.json` location, and the `default` project is created with `data_path: nil` (projects.ex:80). | Implement project-folder discovery: `data_path` := the folder containing `meta/project.json` (never stored in project.json, keeping projects movable — `DiscoverProjectDataPath`); create the default project's folder at a per-user default content location on first launch (never in repo/private_dir); drop the `priv/data/projects/<id>` fallback in `project_data_dir/0`; persist the current project-folder location as a machine-local pointer (project registry) under `private_dir`. Migrate the committed `priv/data/projects/default/` content out of the repo. |
### A2. Spec Should Update (code is normative) ### A2. Spec Should Update (code is normative)
@@ -113,8 +114,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Claim | Spec | Path | | ID | Claim | Spec | Path |
|---|---|---|---| |---|---|---|---|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection | | D1-1 | ~~UniqueMediaTranslation invariant~~ | media.allium:108 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Test exposed a real bug — `Media.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so violations crashed instead of returning a changeset error; fixed `unique_constraint` name to `:media_translations_translation_for_language_index` |
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection | | D1-2 | ~~UniqueTranslationPerLanguage invariant~~ | translation.allium:94 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Same bug as D1-1 — `Posts.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so duplicates crashed instead of returning a changeset error; fixed `unique_constraint` name to `:post_translations_translation_for_language_index` |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found | | D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug | | D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error | | D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
@@ -186,7 +187,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
## Priority Order for Resolution ## Priority Order for Resolution
1. **A1-1 through A1-14c** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index; only A1-14c = Apple GPU/EMLX acceleration still open) 1. ~~**A1-1 through A1-15**~~ — all resolved: auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index, Apple GPU/EMLX acceleration (A1-14c), and preview/generation content strategy (A1-15)
1b. **A1-16** — storage-location compliance: private side done (embeddings index → OS app dir); public side open (data_path discovery from meta/project.json, drop the `priv/data/projects/<id>` fallback, migrate committed default project out of repo)
2. **D1-1 through D1-18** — untested invariants/guarantees 2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code) 3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec 4. **B1-1 through B1-6** — major code behaviors missing from spec

View File

@@ -68,7 +68,10 @@ config :bds, :embeddings,
# Inference is batched: batch_size texts per compiled run, truncated to # Inference is batched: batch_size texts per compiled run, truncated to
# sequence_length tokens. Tuning these trades throughput against memory. # sequence_length tokens. Tuning these trades throughput against memory.
batch_size: 16, batch_size: 16,
sequence_length: 256 sequence_length: 256,
# Hardware acceleration: :auto prefers the Apple GPU (EMLX/Metal) on Apple
# Silicon and falls back to EXLA-CPU elsewhere. Force with :emlx or :exla.
accelerator: :auto
# Cache downloaded model files under the app data directory so they persist # Cache downloaded model files under the app data directory so they persist
# across sessions (ModelCaching invariant). Overridden at runtime in prod. # across sessions (ModelCaching invariant). Overridden at runtime in prod.

View File

@@ -23,8 +23,17 @@ defmodule BDS.Embeddings.Backends.Neural do
compiled for a fixed `batch_size`/`sequence_length` (configurable); compiled for a fixed `batch_size`/`sequence_length` (configurable);
shorter sequences mean less wasted transformer compute. shorter sequences mean less wasted transformer compute.
EXLA on Apple Silicon runs on the CPU — XLA has no Metal/GPU backend. See Hardware acceleration follows the `NativeAcceleratedExecution` invariant.
SPECGAPS A1-14c for the planned EMLX (Apple GPU via MLX) acceleration path. The serving's defn compiler is chosen at build time:
* On Apple Silicon (arm64 macOS) with EMLX available, inference runs on the
Apple GPU via MLX/Metal (`compiler: EMLX`, params placed on the
`EMLX.Backend` GPU device).
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
to `:auto` (default), `:emlx`, or `:exla`.
""" """
@behaviour BDS.Embeddings.Backend @behaviour BDS.Embeddings.Backend
@@ -39,6 +48,7 @@ defmodule BDS.Embeddings.Backends.Neural do
@default_dimensions 384 @default_dimensions 384
@default_batch_size 16 @default_batch_size 16
@default_sequence_length 256 @default_sequence_length 256
@default_accelerator :auto
def child_spec(opts) do def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}} %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
@@ -124,6 +134,8 @@ defmodule BDS.Embeddings.Backends.Neural do
defp build_serving do defp build_serving do
repo = {:hf, Keyword.get(config(), :model_repo, @default_model_repo)} repo = {:hf, Keyword.get(config(), :model_repo, @default_model_repo)}
accelerator = current_accelerator()
maybe_set_default_backend(accelerator)
with {:ok, model_info} <- Bumblebee.load_model(repo), with {:ok, model_info} <- Bumblebee.load_model(repo),
{:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do {:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do
@@ -133,13 +145,58 @@ defmodule BDS.Embeddings.Backends.Neural do
output_attribute: :hidden_state, output_attribute: :hidden_state,
embedding_processor: :l2_norm, embedding_processor: :l2_norm,
compile: [batch_size: batch_size(), sequence_length: sequence_length()], compile: [batch_size: batch_size(), sequence_length: sequence_length()],
defn_options: [compiler: EXLA] defn_options: defn_options(accelerator)
) )
{:ok, serving} {:ok, serving}
end end
end end
# Place model params/tensors on the Apple GPU (Metal) when accelerating with
# EMLX so the compiled inference pass actually runs on-device. EXLA manages
# its own device placement, so nothing to do there.
defp maybe_set_default_backend(:emlx), do: Nx.global_default_backend({EMLX.Backend, device: :gpu})
defp maybe_set_default_backend(:exla), do: :ok
@doc false
@spec defn_options(:emlx | :exla) :: keyword()
def defn_options(:emlx), do: [compiler: EMLX]
def defn_options(:exla), do: [compiler: EXLA]
@doc false
@spec current_accelerator() :: :emlx | :exla
def current_accelerator do
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?())
end
@doc """
Pure accelerator-selection policy for `NativeAcceleratedExecution`.
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured
host still gets working CPU inference instead of crashing.
"""
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, true), do: :emlx
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
defp configured_accelerator do
config() |> Keyword.get(:accelerator, @default_accelerator)
end
defp emlx_available? do
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend)
end
defp apple_silicon? do
:os.type() == {:unix, :darwin} and
to_string(:erlang.system_info(:system_architecture)) =~ ~r/aarch64|arm/
end
defp batch_size do defp batch_size do
config() |> Keyword.get(:batch_size, @default_batch_size) |> max(1) config() |> Keyword.get(:batch_size, @default_batch_size) |> max(1)
end end

View File

@@ -62,6 +62,6 @@ defmodule BDS.Media.Translation do
:updated_at :updated_at
]) ])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :media_translations_translation_language_idx) |> unique_constraint(:language, name: :media_translations_translation_for_language_index)
end end
end end

View File

@@ -79,6 +79,6 @@ defmodule BDS.Posts.Translation do
:updated_at :updated_at
]) ])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :post_translations_translation_language_idx) |> unique_constraint(:language, name: :post_translations_translation_for_language_index)
end end
end end

View File

@@ -268,20 +268,25 @@ defmodule BDS.Projects do
not Repo.exists?(from project in Project, where: project.slug == ^slug) not Repo.exists?(from project in Project, where: project.slug == ^slug)
end end
defp repo_data_dir do
Application.fetch_env!(:bds, BDS.Repo)
|> Keyword.fetch!(:database)
|> Path.expand()
|> Path.dirname()
end
defp project_cache_root do defp project_cache_root do
case Application.get_env(:bds, :project_cache_root) do case Application.get_env(:bds, :project_cache_root) do
root when is_binary(root) -> Path.expand(root) root when is_binary(root) -> Path.expand(root)
_other -> repo_data_dir() # Private app-internal artifacts (e.g. the embeddings index) live under the
# OS private app directory — on macOS ~/Library/Application Support/bds —
# never inside the repo or a project's public folder. Colocating them with
# project_data_dir would pollute (and historically committed to) the repo.
_other -> private_app_dir()
end end
end end
defp private_app_dir do
case :filename.basedir(:user_config, "bds") do
path when is_list(path) -> List.to_string(path)
path -> path
end
|> Path.expand()
end
defp attr(attrs, key) do defp attr(attrs, key) do
cond do cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key) Map.has_key?(attrs, key) -> Map.get(attrs, key)

View File

@@ -36,6 +36,10 @@ defmodule BDS.MixProject do
{:image, "~> 0.67"}, {:image, "~> 0.67"},
{:nx, "~> 0.10"}, {:nx, "~> 0.10"},
{:exla, "~> 0.10"}, {:exla, "~> 0.10"},
# Apple Silicon GPU (Metal) acceleration for embedding inference. Ships
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c).
{:emlx, "~> 0.2.0"},
{:bumblebee, "~> 0.6.3"}, {:bumblebee, "~> 0.6.3"},
{:hnswlib, "~> 0.1.7"}, {:hnswlib, "~> 0.1.7"},
{:stemex, "~> 0.2.1"}, {:stemex, "~> 0.2.1"},
@@ -64,7 +68,7 @@ defmodule BDS.MixProject do
env = Mix.env() env = Mix.env()
[ [
plt_add_apps: [:mix, :inets, :ssl, :nx, :exla, :bumblebee, :hnswlib], plt_add_apps: [:mix, :inets, :ssl, :nx, :exla, :emlx, :bumblebee, :hnswlib],
paths: ["_build/#{env}/lib/bds/ebin"] paths: ["_build/#{env}/lib/bds/ebin"]
] ]
end end

View File

@@ -18,6 +18,7 @@
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"emlx": {:hex, :emlx, "0.2.0", "f844c5456a8051032da98276f1e5c2282ff822824b139e4788af6e48375d0e1e", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "24c674d716beca3daf422829ce7d5fc044981d3d0ca93a832a536016c543dd6f"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},

File diff suppressed because one or more lines are too long

View File

@@ -48,7 +48,8 @@ value EmbeddingModel {
-- Lazy-loaded: pipeline created on first embedding request, not at startup -- Lazy-loaded: pipeline created on first embedding request, not at startup
-- Text preprocessing: prefix all input with "query: " (e5 convention) -- Text preprocessing: prefix all input with "query: " (e5 convention)
-- Pooling: mean pooling + L2 normalization -- Pooling: mean pooling + L2 normalization
-- Loaded on-device via Bumblebee+EXLA; the canonical e5 weights come from -- Loaded on-device via Bumblebee (EMLX/Apple GPU or EXLA-CPU, see
-- NativeAcceleratedExecution); the canonical e5 weights come from
-- the "intfloat/multilingual-e5-small" repository, surfaced under the -- the "intfloat/multilingual-e5-small" repository, surfaced under the
-- "Xenova/multilingual-e5-small" model_id identifier. -- "Xenova/multilingual-e5-small" model_id identifier.
model_id: String -- "Xenova/multilingual-e5-small" model_id: String -- "Xenova/multilingual-e5-small"
@@ -236,10 +237,12 @@ invariant NativeAcceleratedExecution {
-- Inference MUST be batched: batch_size inputs are run per compiled -- Inference MUST be batched: batch_size inputs are run per compiled
-- inference pass and inputs are truncated to a bounded sequence_length, so -- inference pass and inputs are truncated to a bounded sequence_length, so
-- (re)indexing many posts is not serialised one document at a time. -- (re)indexing many posts is not serialised one document at a time.
-- Current implementation: Bumblebee + EXLA, which is native CPU on Apple -- Current implementation: Bumblebee with a runtime-selected defn compiler.
-- Silicon (XLA has no Metal backend); neighbour search is HNSW (hnswlib). -- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal,
-- Apple GPU acceleration via EMLX/MLX is tracked as a follow-up -- params placed on the EMLX.Backend GPU device); everywhere else, and as a
-- (SPECGAPS A1-14c). -- fallback when EMLX is unavailable, it runs on optimised native CPU via
-- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto).
-- Neighbour search is HNSW (hnswlib).
} }
invariant ModelCaching { invariant ModelCaching {

View File

@@ -186,7 +186,7 @@ rule UpsertMediaTranslation {
rule RebuildMediaFromFiles { rule RebuildMediaFromFiles {
when: RebuildMediaFromFilesRequested(project) when: RebuildMediaFromFilesRequested(project)
-- Scans media directory for .meta sidecars, reimports to DB -- Scans media directory for .meta sidecars, reimports to DB
for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"): for sidecar in scan_directory(project.public_dir + "/media", "*.meta"):
let parsed = parse_sidecar(sidecar) let parsed = parse_sidecar(sidecar)
ensures: Media.created(parsed) ensures: Media.created(parsed)
-- or updated if already exists -- or updated if already exists

View File

@@ -61,7 +61,7 @@ rule RunMetadataDiff {
) )
-- Detect orphan files (on disk but not in DB) -- Detect orphan files (on disk but not in DB)
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"): for file in scan_directory(project.public_dir + "/posts", "*.md"):
let matching = Posts where file_path = file let matching = Posts where file_path = file
if matching.count = 0: if matching.count = 0:
ensures: OrphanReport.created(file_path: file) ensures: OrphanReport.created(file_path: file)

View File

@@ -8,6 +8,7 @@ surface ProjectControlSurface {
provides: provides:
CreateProjectRequested(name, data_path) CreateProjectRequested(name, data_path)
OpenProjectRequested(folder_path)
SetActiveProjectRequested(project) SetActiveProjectRequested(project)
DeleteProjectRequested(project) DeleteProjectRequested(project)
} }
@@ -27,11 +28,33 @@ entity Project {
tags: Tag with project = this tags: Tag with project = this
-- Derived -- Derived
internal_base_dir: String --
-- {user_data}/projects/{id}/ -- data_path is the project folder: the directory that CONTAINS
-- Contains: meta/, thumbnails/, tags.json -- meta/project.json. It is DISCOVERED from the project.json location at
effective_data_dir: data_path ?? internal_base_dir -- load time and is never written into project.json itself — so the folder
-- Custom data path overrides default -- can be moved/renamed freely. The current location is remembered only as a
-- machine-local pointer (a project registry under private_dir), never
-- embedded in the portable project. See DataPathNotPersistedInProjectJson.
public_dir: data_path
-- All user-owned, portable, webserver-bound content lives here, under
-- the project folder:
-- posts (.md), media + thumbnails, templates/, scripts/,
-- meta/ (project.json, categories.json, category-meta.json,
-- publishing.json), tags.json, menu.opml, and generated html/ output.
-- See PublicContentLivesInProjectFolder.
private_dir: String
-- The OS per-user app-data directory. Holds app-internal,
-- machine-specific, regenerable artifacts ONLY — never inside the repo
-- or the project folder:
-- the SQLite database, the per-project embeddings index
-- (projects/{id}/embeddings.usearch + .meta.json sidecar), the
-- downloaded embedding-model cache, the project registry, and
-- window/UI state.
-- OS-specific base (resolved via :filename.basedir, app name "bds"):
-- macOS: ~/Library/Application Support/bds (:user_config)
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
-- Windows: %APPDATA%\\bds
-- See PrivateArtifactsLiveInOsAppDir.
} }
surface ProjectSurface { surface ProjectSurface {
@@ -48,8 +71,8 @@ surface ProjectSurface {
project.posts.count project.posts.count
project.media.count project.media.count
project.tags.count project.tags.count
project.internal_base_dir project.public_dir
project.effective_data_dir project.private_dir
} }
invariant SingleActiveProject { invariant SingleActiveProject {
@@ -73,11 +96,52 @@ rule CreateProject {
data_path: data_path, data_path: data_path,
is_active: false is_active: false
) )
@guidance
-- data_path is the chosen project folder. CreateProject writes
-- meta/project.json into {data_path}/meta/ but never records data_path
-- inside it (DataPathNotPersistedInProjectJson). The default project's
-- folder is created at a per-user default content location on first
-- launch — never inside the application repo or private_dir.
}
rule DiscoverProjectDataPath {
-- Opening or loading a project folder deduces its data_path from the
-- on-disk location of meta/project.json rather than from any stored value,
-- keeping projects movable (DataPathNotPersistedInProjectJson).
when: OpenProjectRequested(folder_path)
-- folder_path contains meta/project.json
let project = Projects where data_path = folder_path
ensures: project.data_path = folder_path
} }
invariant ProjectTemplatesDirectoryReservedForUserTemplates { invariant ProjectTemplatesDirectoryReservedForUserTemplates {
-- The project templates directory stores only user-managed templates. -- The project templates directory stores only user-managed templates.
-- Creating a project does not populate effective_data_dir/templates with bundled defaults. -- Creating a project does not populate public_dir/templates with bundled defaults.
}
invariant PublicContentLivesInProjectFolder {
-- Every file the user owns or that must be served by the webserver lives
-- under public_dir (= the project folder containing meta/project.json):
-- posts, media + thumbnails, templates, scripts, the meta/ JSON files,
-- tags.json, menu.opml, and the generated html/ output. None of this
-- content is ever written to private_dir or into the application repo.
}
invariant PrivateArtifactsLiveInOsAppDir {
-- App-internal, machine-specific, regenerable artifacts — the SQLite
-- database, the per-project embeddings index and its sidecar, the
-- model cache, the project registry, and window/UI state — live ONLY
-- under private_dir (the OS per-user app-data directory). They are never
-- written into a project folder (public_dir) or into the application repo.
-- A regenerable artifact (e.g. the embeddings index) may be rebuilt from
-- the database when absent.
}
invariant DataPathNotPersistedInProjectJson {
-- meta/project.json never stores its own path or data_path. A project's
-- location is determined solely by where its meta/project.json file sits,
-- so the folder can be moved or renamed without editing any file. The app
-- remembers the current location as a machine-local pointer in private_dir.
} }
rule SetActiveProject { rule SetActiveProject {

View File

@@ -268,7 +268,7 @@ rule ExecuteTransform {
rule RebuildScriptsFromFiles { rule RebuildScriptsFromFiles {
when: RebuildScriptsFromFilesRequested(project) when: RebuildScriptsFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/scripts", "*." + config.script_extension): for file in scan_directory(project.public_dir + "/scripts", "*." + config.script_extension):
let parsed = parse_script_file(file) let parsed = parse_script_file(file)
ensures: Script.created(parsed) ensures: Script.created(parsed)
} }

View File

@@ -81,7 +81,7 @@ invariant UserTemplateDirectoryOverridesBundledDefaults {
} }
invariant RebuildTemplatesIndexesOnlyProjectTemplates { invariant RebuildTemplatesIndexesOnlyProjectTemplates {
-- Rebuild-from-files scans only project.effective_data_dir/templates. -- Rebuild-from-files scans only project.public_dir/templates.
-- Bundled defaults are render-time fallbacks and are not indexed into Templates -- Bundled defaults are render-time fallbacks and are not indexed into Templates
-- unless the user has created matching project files. -- unless the user has created matching project files.
} }
@@ -171,7 +171,7 @@ rule CascadeSlugUpdate {
rule RebuildTemplatesFromFiles { rule RebuildTemplatesFromFiles {
when: RebuildTemplatesFromFilesRequested(project) when: RebuildTemplatesFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"): for file in scan_directory(project.public_dir + "/templates", "*.liquid"):
let parsed = parse_template_file(file) let parsed = parse_template_file(file)
ensures: Template.created(parsed) ensures: Template.created(parsed)
-- or updated if slug already exists -- or updated if slug already exists

View File

@@ -36,4 +36,36 @@ defmodule BDS.Embeddings.Backends.NeuralTest do
assert BDS.Embeddings.Backend in behaviours assert BDS.Embeddings.Backend in behaviours
end end
describe "accelerator selection (NativeAcceleratedExecution)" do
test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do
assert Neural.select_accelerator(:auto, true, true) == :emlx
end
test "auto falls back to EXLA-CPU off Apple Silicon" do
assert Neural.select_accelerator(:auto, true, false) == :exla
end
test "auto falls back to EXLA-CPU when EMLX is unavailable" do
assert Neural.select_accelerator(:auto, false, true) == :exla
end
test "explicit :exla is honoured even on Apple Silicon with EMLX present" do
assert Neural.select_accelerator(:exla, true, true) == :exla
end
test "explicit :emlx is honoured when available" do
assert Neural.select_accelerator(:emlx, true, true) == :emlx
assert Neural.select_accelerator(:emlx, true, false) == :emlx
end
test "explicit :emlx degrades to EXLA when EMLX is unavailable" do
assert Neural.select_accelerator(:emlx, false, true) == :exla
end
test "defn options map each accelerator to its native compiler" do
assert Neural.defn_options(:emlx) == [compiler: EMLX]
assert Neural.defn_options(:exla) == [compiler: EXLA]
end
end
end end

View File

@@ -524,6 +524,59 @@ defmodule BDS.MediaTest do
assert contents =~ "caption: \"Bildunterschrift\"\n---" assert contents =~ "caption: \"Bildunterschrift\"\n---"
end end
test "UniqueMediaTranslation: a media has at most one translation per language", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "hello media")
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert {:ok, first} =
BDS.Media.upsert_media_translation(media.id, "de", %{title: "Titel"})
# Re-upserting the same language updates the existing row instead of adding a second.
assert {:ok, second} =
BDS.Media.upsert_media_translation(media.id, "de", %{title: "Geändert"})
assert second.id == first.id
assert [persisted] =
BDS.Media.Translation
|> where([t], t.translation_for == ^media.id and t.language == "de")
|> Repo.all()
assert persisted.title == "Geändert"
# A direct insert of a distinct row with the same (media, language) is rejected by the
# unique constraint backing the invariant.
now = BDS.Persistence.now_ms()
duplicate =
BDS.Media.Translation.changeset(%BDS.Media.Translation{}, %{
id: Ecto.UUID.generate(),
project_id: media.project_id,
translation_for: media.id,
language: "de",
title: "Duplicate",
created_at: now,
updated_at: now
})
assert {:error, changeset} = Repo.insert(duplicate)
assert %{language: ["has already been taken"]} = errors_on(changeset)
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
defp tiny_jpeg_binary do defp tiny_jpeg_binary do
Image.new!(3, 2, color: [255, 0, 0]) Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85) |> Image.write!(:memory, suffix: ".jpg", quality: 85)

View File

@@ -310,6 +310,61 @@ defmodule BDS.PostTranslationsTest do
assert report.do_not_translate_posts == [ignored_post.id] assert report.do_not_translate_posts == [ignored_post.id]
end end
test "UniqueTranslationPerLanguage: a post has at most one translation per language", %{
project: project
} do
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Canonical Post",
content: "Hello world",
language: "en"
})
assert {:ok, first} =
Posts.upsert_post_translation(post.id, "de", %{title: "Titel"})
# Re-upserting the same language updates the existing row instead of adding a second.
assert {:ok, second} =
Posts.upsert_post_translation(post.id, "de", %{title: "Geändert"})
assert second.id == first.id
assert [persisted] =
BDS.Posts.Translation
|> where([t], t.translation_for == ^post.id and t.language == "de")
|> Repo.all()
assert persisted.title == "Geändert"
# A direct insert of a distinct row with the same (post, language) is rejected by the
# unique constraint backing the invariant, returning a changeset error rather than crashing.
now = BDS.Persistence.now_ms()
duplicate =
BDS.Posts.Translation.changeset(%BDS.Posts.Translation{}, %{
id: Ecto.UUID.generate(),
project_id: post.project_id,
translation_for: post.id,
language: "de",
title: "Duplicate",
status: :draft,
created_at: now,
updated_at: now
})
assert {:error, changeset} = Repo.insert(duplicate)
assert %{language: ["has already been taken"]} = errors_on(changeset)
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
defp configure_auto_translation_test_runtime do defp configure_auto_translation_test_runtime do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
AI.put_endpoint( AI.put_endpoint(

View File

@@ -117,6 +117,32 @@ defmodule BDS.ProjectsTest do
assert Enum.count(BDS.Projects.list_projects(), & &1.is_active) == 1 assert Enum.count(BDS.Projects.list_projects(), & &1.is_active) == 1
end end
test "project_cache_dir never falls back into the project data directory" do
# Private app-internal artifacts (the embeddings index) must live under the
# OS private app directory (macOS: ~/Library/Application Support/bds), never
# inside priv/data/projects/<id> — leaving them in the project tree pollutes
# the repository.
saved = Application.get_env(:bds, :project_cache_root)
Application.delete_env(:bds, :project_cache_root)
on_exit(fn -> Application.put_env(:bds, :project_cache_root, saved) end)
project_id = "fallback-#{System.unique_integer([:positive])}"
cache_dir = BDS.Projects.project_cache_dir(project_id)
data_dir = Path.expand("../../priv/data/projects/#{project_id}", __DIR__)
refute cache_dir == data_dir
refute String.starts_with?(cache_dir, Path.expand("../../priv/data", __DIR__))
private_app_dir =
case :filename.basedir(:user_config, "bds") do
path when is_list(path) -> List.to_string(path)
path -> path
end
|> Path.expand()
assert String.starts_with?(cache_dir, private_app_dir)
end
test "ensure_default_project creates the default project once and keeps it active" do test "ensure_default_project creates the default project once and keeps it active" do
Repo.delete_all(Project) Repo.delete_all(Project)