Compare commits
5 Commits
d03d033548
...
9d5764b251
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d5764b251 | |||
| 3a77761f96 | |||
| aff4b63188 | |||
| 91b0ffe4c5 | |||
| 84b91750fb |
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
10
SPECGAPS.md
10
SPECGAPS.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
6
mix.exs
6
mix.exs
@@ -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
|
||||||
|
|||||||
1
mix.lock
1
mix.lock
@@ -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"},
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user