Skip to content

feat: Tier 2 semantic categorization + SONA-enhanced flow detection - #30

Merged
pacphi merged 9 commits into
mainfrom
feat/tier2-ruvector
Apr 19, 2026
Merged

feat: Tier 2 semantic categorization + SONA-enhanced flow detection#30
pacphi merged 9 commits into
mainfrom
feat/tier2-ruvector

Conversation

@pacphi

@pacphi pacphi commented Apr 19, 2026

Copy link
Copy Markdown
Owner

Summary

Delivers ADR-012 (Tier 2 of the categorization cascade) and ADR-017 (SONA-enhanced flow detection) across four phases on feat/tier2-ruvector:

  • Phase 1 — DB schema + CascadeEngine Tier 2 wiring; Tier2Config, RuVectorEmbeddingStore, bootstrap_semantic behind the sona feature with Jaccard fallback. bootstrap_tier2 bin + POST /api/categorize/with-vector.
  • Phase 2FlowPatternMatcher trait + RuVectorPatternMatcher (HNSW keyed per-source-account so patterns never leak across accounts); confirm/dismiss feedback with half-life decay on dismiss in handlers/flows.rs::update_flow. bootstrap_flows bin.
  • Phase 3 (A/B/C/3.5) — new finima-embed crate with EmbeddingProvider trait and three real backends: NoopEmbedder, OllamaEmbedder, CandleEmbedder (candle-metal / candle-cuda acceleration). No stubs survive — Phase 3.5 replaced the Candle compilation shim with a real BertModel loader. EMBEDDER= Makefile flag mirrors LLM=. Prometheus metrics for Tier 2, flow patterns, and bootstrap.
  • Phase 4 — ADR-012 + ADR-017 promoted to Accepted; new docs/guides/embedder.md; categorization guide refreshed.

Phase 0 research (already landed on main via #22)

docs/spikes/ruvector-phase0.md — full findings from the four research spikes (API probe, ruvector-sona validation, persistence round-trip, MicroLoRA training-path verification). Ships as the foundation every downstream decision in this PR cites.

Tests

  • finima-categorize — 68 tests with sona; Jaccard + RuVector store unit tests, engine integration tests, bootstrap counters.
  • finima-analysis — 62 tests with sona; RuVectorPatternMatcher construction + dim rejection + per-source-account isolation + SONA state round-trip; resolve_one_sided_flows[_with_vectors].
  • finima-embed — Noop, Ollama, Candle unit tests under their feature gates. Real MiniLM end-to-end test is #[ignore]d (gated behind cargo test --features candle -- --ignored to avoid CI model download).
  • finima-api — workspace lib tests pass on default + sona + embedder-* feature combinations.

Defaults

Everything feature-gated and off by default. cargo build with no flags compiles a binary with backend: jaccard for Tier 2 and backend: none for the embedder — no RuVector, no Ollama, no Candle in the dependency graph. Operators enable the stack with one flag:

make start LLM=ollama          # Ollama LLM + Ollama embedder
make start EMBEDDER=candle     # in-process Candle BertModel
make start LLM=ollama EMBEDDER=none    # Ollama LLM, no embedder

Migrations

  • 025_embedding_index.sql — Tier 2 persisted embeddings + indices
  • 026_portfolio_sona_state.sql — ReasoningBank snapshot column on portfolios
  • 027_flow_patterns_embedding.sqldescription_embedding BYTEA + embedding_dim INTEGER on flow_patterns

Reviewer checklist

  • Default build (cargo build) — no regressions
  • sona feature build (cargo build --features finima-categorize/sona,finima-analysis/sona)
  • Embedder feature builds (cargo build --features embedder-ollama, embedder-candle)
  • make lint exit 0
  • CI gates green (backend lint, security audit, markdown)

References

  • docs/ADRs/ADR-012-tiered-categorization-engine.md
  • docs/ADRs/ADR-017-sona-enhanced-flow-detection.md
  • docs/guides/embedder.md
  • docs/spikes/ruvector-phase0.md

🤖 Generated with claude-flow

pacphi and others added 9 commits April 17, 2026 13:21
Phase 1 scaffolding for the RuVector-backed Tier 2 semantic store
(ADR-012, ADR-017). Off by default — the existing Jaccard n-gram store
remains the Tier 2 default. Enabling is a two-step:
  1. cargo build -p finima-api --features finima-categorize/sona
  2. set `categorize.tier2.backend: ruvector` in config/categorize.yaml

What landed:

* `config/categorize.yaml` — new YAML section mirroring the existing
  `recurring.yaml` precedent. Externalizes fuzzy/pattern/semantic
  thresholds and adds a `tier2.{backend,dim,hnsw_*,bootstrap_*}` block.
  `dim` defaults to 384 (matches all-MiniLM-L6-v2).

* `finima-categorize::config` — adds `Tier2Backend` (Jaccard|RuVector)
  and `Tier2Config`. `Tier2Config::resolved_backend()` downgrades to
  Jaccard with a tracing warning if `ruvector` is requested but the
  `sona` feature is not compiled in, so misconfigured deploys degrade
  safely instead of panicking.

* `finima-categorize/Cargo.toml` — optional `ruvector-core = "2.1"`
  (hnsw+simd+memory-only) and `ruvector-sona = "0.1"` (serde-support).
  Feature name is `sona`, matching `finima-analysis` so both crates
  opt in together.

* `finima-categorize::tier2::SemanticVectorIngest` — new extension
  trait that adds `learn_with_vector(.., vector: Option<&[f32]>)`.
  Jaccard implements it by ignoring the vector (lexical backend);
  RuVector implements it as the primary ingest path and rejects
  dim-mismatched or vector-less inputs.

* `finima-categorize::tier2::ruvector_store` (feature-gated) — the new
  `RuVectorEmbeddingStore` built on `ruvector_core::VectorDB` + HNSW
  with `ruvector_sona::SonaEngine` carrying the ReasoningBank state
  (MicroLoRA deferred per Phase 0d). Includes `snapshot_sona_state` /
  `restore_sona_state` so the engine can survive restart via Postgres
  per the Phase 0c design. 4 unit tests cover construction, dim
  rejection, nearest-neighbor retrieval, and SONA state round-trip.

* `finima-categorize::tier2::bootstrap` — explicit, observable cold-
  start path. `bootstrap_semantic(&mut store, seed_iter, max_examples)
  -> (BootstrapReport, Option<BootstrapError>)` returns offered/
  inserted/skipped/rejected counts + elapsed duration, emits a
  structured tracing event. No hidden auto-seeding on engine
  construction.

* `finima-api::config` — mirrors the crate-level structs as
  `CategorizeYamlConfig` / `Tier2YamlConfig` for serde-based YAML
  loading; `From` conversions map them onto the crate types. Adds
  `categorize` to the section-files list. `validate_config` logs the
  resolved Tier 2 backend + dim at startup so operators can see which
  backend actually runs.

Scope boundaries honored:
  - No new database tables yet (embedding_index / sona_state columns
    land when we wire the store into the CascadeEngine / API handlers).
  - No embedder in-tree; Phase 1 is bring-your-own vectors.
  - MicroLoRA gradient updates deferred; ReasoningBank-only.

Verified locally:
  - `cargo build -p finima-categorize` (no feature) ✓
  - `cargo build -p finima-categorize --features sona` ✓
  - `cargo test -p finima-categorize --features sona` — 65/65 ✓
  - `cargo clippy --workspace --all-targets -- -D warnings` ✓
  - `cargo build -p finima-api` ✓

Co-Authored-By: claude-flow <ruv@ruv.net>
Round 1 of Phase 1 — two independent deliverables landed together:

1.A (finima-db): schema + repos for the ADR-012 EmbeddingIndex and the
    Phase 0c SONA state snapshot.
  * migrations/025_embedding_index.sql — portfolio-scoped labeled-example
    store with nullable BYTEA embedding + embedding_dim, indexed on
    portfolio_id and (portfolio_id, category, subcategory).
  * migrations/026_portfolios_sona_state.sql — adds sona_state JSONB +
    sona_state_updated_at TIMESTAMPTZ to portfolios for periodic
    snapshotting of ReasoningBank state.
  * repos/embedding_index_repo.rs — `EmbeddingIndexRepo` with
    `insert / list_for_portfolio / count_for_portfolio /
    delete_for_portfolio`. Uses sqlx::query_as::<_, Row> runtime form
    so builds don't require live DB.
  * repos/portfolio_repo.rs — adds an inherent `impl PgPortfolioRepo`
    block with `save_sona_state` / `load_sona_state`, returning
    Result<_, sqlx::Error>. Trait interface unchanged.

1.B (finima-categorize): `CascadeEngine` helpers that centralize Tier 2
    backend selection so API callers don't need to know about the split.
  * `CascadeEngine::build_semantic_from_config(cfg, min_conf)` returns
    an `Arc<RwLock<dyn SemanticCategorizer>>` — Jaccard by default,
    RuVector when the `sona` feature is enabled. Falls back safely
    (Tier2Config::resolved_backend warns + downgrades off-feature).
  * `CascadeEngine::with_semantic_from_config(...)` — chained builder.
  * `SemanticBuildError` (thiserror) wraps backend construction errors.
  * Three new unit tests: Jaccard build, builder chaining,
    RuVector build under `#[cfg(feature = "sona")]`.

Verified:
  * `make lint` exit 0 (markdown + YAML + clippy 1.95 + eslint).
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona -- -D warnings` clean.
  * `cargo test -p finima-categorize` — 62/62 (+3).
  * `cargo test -p finima-categorize --features sona` — 68/68 (+3).
  * `cargo test --workspace --lib` — 47/47 on lib tests.

Next: 1.C wires the bootstrap bin, handler path, and AppState integration.

Co-Authored-By: claude-flow <ruv@ruv.net>
Round 2 of Phase 1 — puts all the earlier scaffolding onto request
paths.

* New bin `finima-api/bin/bootstrap_tier2.rs` — maintainer CLI to seed
  the Tier 2 store from existing confirmed categorizations. Flags:
  `--portfolio-id UUID` (default: all portfolios), `--dry-run`,
  `--limit N` (overrides categorize.tier2.bootstrap_max_examples).
  Builds backend directly (Jaccard or RuVector under `sona`) so it
  can call `bootstrap_semantic` via the `SemanticVectorIngest` trait.
  Writes a Phase 1 state marker via `save_sona_state`; the full
  ReasoningBank snapshot ships in Phase 2.

* `AppState` now exposes `semantic_tier2() -> Arc<RwLock<dyn
  SemanticCategorizer>>` for text-only callers, and a feature-gated
  `semantic_tier2_ruvector() -> Option<Arc<RwLock<RuVectorEmbeddingStore>>>`
  for callers that can supply a precomputed vector. Both are built
  inside `AppState::new` from the already-loaded `AppConfig`;
  `main.rs` signature unchanged. Two handles rather than downcasting
  a trait object — Rust doesn't allow downcasting `dyn Trait`.

* New handler `categorize_transaction_with_vector` at
  `POST /api/categorize/with-vector`. Accepts
  `CategorizeWithVectorRequest { precomputed_vector: Option<Vec<f32>>,
  ... }`. When `sona` is enabled and both vector + RuVector backend
  are present, routes via `ruvector_store::categorize_with_vector`;
  otherwise falls through to the existing text-only probe. Existing
  `POST /api/categorize` remains untouched.

* `finima-api/Cargo.toml` gets a `sona = ["finima-categorize/sona"]`
  feature so `#[cfg(feature = "sona")]` compiles inside this crate.
  Default build does NOT enable it.

Verified:
  * `make lint` exit 0.
  * `cargo build -p finima-api` (default features) ✓
  * `cargo build -p finima-api --features finima-categorize/sona` ✓
  * `cargo build -p finima-api --bin bootstrap_tier2` ✓
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona -- -D warnings` clean.
  * `cargo test --workspace --lib` — 47/47.
  * `cargo test -p finima-categorize --features sona` — 68/68.

Phase 1 scaffolding is now end-to-end: config → AppState → handler
and bootstrap bin. Phase 2 starts next: flow-pattern matcher and
ADR-017 flow detection integration.

Co-Authored-By: claude-flow <ruv@ruv.net>
ADR-017 implementation, Round 1. Two independent deliverables landed
together:

2.A (finima-db):
  * migrations/027_flow_patterns_embedding.sql — adds
    description_embedding BYTEA + embedding_dim INTEGER columns to
    the existing flow_patterns table; adds composite
    idx_flow_patterns_source_target index for confirm/dismiss lookups.
  * repos/flow_pattern_repo.rs — new FlowPatternRepo with
    - insert (one-shot)
    - upsert_confirmed (transactional SELECT FOR UPDATE; increments
      match_count, keeps higher confidence, preserves existing
      embedding when incoming is None)
    - list_for_portfolio / list_for_source / count_for_portfolio
    - delete_for_portfolio
    - record_dismissal (half-life decay: confidence *= 0.5, clamped)

2.B (finima-analysis): RuVector-backed flow-pattern matcher behind
    the existing `sona` feature flag (which now pulls the real
    optional `ruvector-core` + `ruvector-sona` deps).
  * New module `sona::ruvector_backend` (feature-gated) with
    RuVectorPatternMatcher, RuVectorPatternMatcherConfig,
    RuVectorPatternMatcherError.
  * HNSW (cosine, memory-only) + ReasoningBank via SonaEngine.
    snapshot_sona_state / restore_sona_state accessors (the
    MicroLoRA path is intentionally unwired; Phase 0d spike showed
    it has no effect under single-step trajectories and doesn't
    survive restart anyway).
  * Primary API is `store_pattern_with_vector(FlowPattern, &[f32])`
    and `infer_target_with_vector(description, source_account_id,
    query, min_confidence) -> Option<InferredTarget>` — the latter
    enforces `source_account_id` equality via metadata filtering, so
    a pattern learned for account A never leaks into account B.
  * Trait `FlowPatternMatcher` methods stay as safe no-ops; callers
    with no embedding fall through to the existing heuristic path
    without misclassifying.
  * 8 new unit tests: construct/ingest, dim rejection (store + query),
    same-source match, other-source isolation, threshold filtering,
    sona state round-trip, trait no-ops.

Verified:
  * `cargo build -p finima-analysis --features sona` ✓
  * `cargo test -p finima-analysis --features sona --lib` — 57/57 (+10)
  * `cargo build -p finima-db` ✓
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona,finima-analysis/sona -- -D warnings` clean
  * `make lint` exit 0

Next: 2.C wires the matcher into detect_flows + confirm/dismiss
handlers + AppState.

Co-Authored-By: claude-flow <ruv@ruv.net>
ADR-017 Phase 2, Round 2. Puts the flow-pattern matcher on live
request paths while keeping default-feature builds unaffected.

* `finima-analysis/src/flows.rs`:
  - `resolve_one_sided_flows(&mut [FlowCandidate], &[String], &M,
     min_confidence)` — trait-driven, unconditional. Populates
    `target_account_id` on one-sided candidates whose heuristic match
    failed but whose description is a known flow pattern per the
    matcher. Skips any candidate that already has a target.
  - `resolve_one_sided_flows_with_vectors(...)` (sona-gated) —
    same contract but routes via `RuVectorPatternMatcher`'s
    `infer_target_with_vector`; `query_vectors[i]` aligns with
    `candidates[i]`. Empty / shorter vector slices degrade to no-op
    for that candidate.
  - 5 new tests: 3 trait-level (fake matcher) + 2 sona-gated
    (real `RuVectorPatternMatcher`). Total finima-analysis test
    count: 62 (+5).

* `finima-api/src/state.rs`:
  - `flow_pattern_repo: Arc<FlowPatternRepo>` — always-on.
  - `flow_matcher: Arc<RwLock<dyn FlowPatternMatcher>>` — defaults
    to `StubPatternMatcher`; always available.
  - `flow_matcher_ruvector: Option<Arc<RwLock<RuVectorPatternMatcher>>>`
    — sona-gated; constructed from `categorize.tier2.{dim, hnsw_*}`
    with a `StubPatternMatcher` fallback on build error.
  - Getters mirror the Tier 2 pattern. `AppState::new` signature
    unchanged; configuration read from existing `AppConfig`.

* `finima-api/src/handlers/flows.rs`:
  - `update_flow` now loads the source transaction (warn + skip on
    lookup failure) and, on success:
      * `action == "confirm"` with a target → `flow_pattern_repo.
        upsert_confirmed(NewFlowPattern { ... confidence: 1.0, ... })`
        then `matcher.store_pattern(...)` (trait no-op on RuVector).
      * `action == "dismiss"` → `flow_pattern_repo.record_dismissal
        (portfolio, source, &description)` (half-life decay)
        then `matcher.record_dismissal(&description, source)`.
  - Pattern-repo errors are logged at WARN; they never fail the
    primary confirm/dismiss operation.

* `finima-api/src/bin/bootstrap_flows.rs` (new) — maintainer CLI
  that seeds the flow-pattern repo from historically confirmed
  flows. Mirrors `bootstrap_tier2`: `--portfolio-id`, `--dry-run`,
  `--limit N`. Per-portfolio report with
  `offered / inserted / skipped / elapsed_ms`.

* `finima-api/Cargo.toml`:
  - New `[[bin]] bootstrap_flows`.
  - `sona` feature now forwards to both `finima-categorize/sona`
    AND `finima-analysis/sona` so one feature flag enables the
    whole stack.

Verified:
  * `cargo build -p finima-api` (no features) ✓
  * `cargo build -p finima-api --features finima-categorize/sona` ✓
  * `cargo build -p finima-api --bin bootstrap_flows` ✓
  * `cargo test --workspace --lib` — 47/47 on lib tests
  * `cargo test -p finima-analysis --features sona --lib` — 62/62
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona,finima-analysis/sona -- -D warnings` clean
  * `make lint` exit 0

**Phase 2 complete.** Matcher learns on confirm, decays on dismiss,
and is ready to answer k-NN queries as soon as Phase 3 plugs in an
embedder.

Co-Authored-By: claude-flow <ruv@ruv.net>
…w metrics

3.A (new crate `finima-embed`):
  * Workspace member added; 384-dim Jaccard-matching default.
  * `EmbeddingProvider` async trait: `embed`, `embed_batch` (default
    sequential), `dim`, `backend`. Errors: BackendUnavailable, Http,
    Parse, DimMismatch, Timeout, Other.
  * `NoopEmbedder` — always returns BackendUnavailable("none").
    Used when EMBEDDER=none so the system degrades cleanly rather
    than silently producing wrong vectors.
  * `OllamaEmbedder` (feature `ollama`) — POSTs
    `{base_url}/api/embeddings` with `{model, prompt}`, parses
    `{embedding: [...]}`, enforces configured `dim`, and
    L2-normalizes before returning. Configurable per-request
    timeout (default 30s).
  * `CandleEmbedder` (feature `candle`) — Phase 3 compilation stub
    that gates the feature so `EMBEDDER=candle` builds link cleanly.
    At runtime returns BackendUnavailable("candle: not yet wired")
    with a tracing warning. Full sentence-transformer loader ships
    in a Phase 3.5 follow-up; keeping it out of this PR avoids
    slipping scope on the matcher/bootstrap wiring.
  * Features: `ollama`, `candle`, `candle-metal`, `candle-cuda` —
    mirrors the existing LLM flag family.
  * Tests: 3 ollama (construction + l2 normalize), 2 candle
    (stub errors with expected metadata), 1 noop.

3.B (metrics):
  * Eleven new fields on `MetricsRegistry`, all registered with
    snake_case names + HELP text in the existing style:
    - tier2_queries_total (backend × outcome)
    - tier2_search_latency_seconds (backend) — exp buckets
      0.0001..~3s
    - tier2_bootstrap_{inserted,rejected}_total
    - tier2_index_size (gauge)
    - flow_pattern_queries_total (backend × outcome)
    - flow_pattern_search_latency_seconds (backend) — same buckets
    - flow_patterns_{confirmed,dismissed}_total
    - flow_pattern_index_size (gauge)
    - bootstrap_duration_seconds (component × result) — exp
      buckets 0.01..~26min
  * Label structs derive EncodeLabelSet.

Round 2 (Makefile EMBEDDER= flag, config.yaml extension, bootstrap bins
calling the embedder, handler metric call sites, AppState wiring) is
intentionally deferred to the next commit so reviewers can see the
abstraction shape before the integration lands.

Verified:
  * `cargo build -p finima-embed` (no features) ✓
  * `cargo build -p finima-embed --features ollama` ✓
  * `cargo build -p finima-embed --features candle` ✓
  * `cargo test -p finima-embed --features ollama,candle` — 6/6 ✓
  * `cargo clippy -p finima-embed --features ollama,candle
     --all-targets -- -D warnings` clean
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona,finima-analysis/sona -- -D warnings` clean
  * `make lint` exit 0

Co-Authored-By: claude-flow <ruv@ruv.net>
…wiring

Integration round that puts the Phase 3.A `finima-embed` crate and the
Phase 3.B metrics onto live paths. No external / paid providers — only
`none` (Noop), `ollama` (HTTP), `candle` (local; stub runtime for now,
real impl lands in Phase 3.5).

* `Makefile` — new `EMBEDDER=` block mirroring the LLM flag family.
  `EMBEDDER ?= $(LLM)` so `make start LLM=ollama` transparently enables
  the Ollama embedder. Auto-promotes bare `candle` to `candle-metal` /
  `candle-cuda` on Apple Silicon / NVIDIA, same as LLM. `CARGO_EMBED_FEATURES`
  is appended beside `CARGO_LLM_FEATURES` on every cargo invocation;
  cargo accepts multiple `--features` flags, keeping the two orthogonal.

* `config/categorize.yaml` — new `embedder:` section with `backend`,
  `dim`, `ollama.{url,model,timeout_millis}`, `candle.model_id`.

* `finima-api/Cargo.toml` — `finima-embed` is a hard dep (NoopEmbedder
  is unconditional); new feature forwards `embedder-ollama`,
  `embedder-candle`, `embedder-candle-metal`, `embedder-candle-cuda`
  gate the heavier backends.

* `finima-api/src/config.rs` — `EmbedderYamlConfig` +
  `EmbedderOllamaConfig` + `EmbedderCandleConfig`; registered on
  `AppConfig`; `"embedder"` added to the section-files list;
  `validate_config` logs the resolved backend + dim at startup.

* `finima-api/src/state.rs` — `embedder: Arc<dyn EmbeddingProvider>`
  built by a compile-time-aware `build_embedder()` that honors
  the YAML backend string when the matching feature is enabled and
  falls back to Noop with a warn log otherwise. Metrics handle is
  now attached post-construction via `set_metrics` so it's available
  to all handlers via `AppState::metrics()`.

* `finima-api/src/main.rs` — calls `state.set_metrics(...)` after the
  registry is initialized.

* `handlers/flows.rs` — on confirm, embed the description when the
  embedder is non-noop, persist `(embedding bytes, embedding_dim)`
  on the `NewFlowPattern` row, and additionally route through
  `store_pattern_with_vector` on the RuVector matcher handle when
  present. Increments `flow_patterns_confirmed_total` /
  `flow_patterns_dismissed_total` per action.

* `handlers/categorization.rs` — `POST /api/categorize/with-vector`
  now falls back to server-side embedding when the request's
  `precomputed_vector` is absent and the embedder is non-noop. The
  Tier 2 probe is wrapped with `tier2_search_latency_seconds` timing
  and `tier2_queries_total{backend,outcome}` counter bumps.

* `bin/bootstrap_tier2.rs` + `bin/bootstrap_flows.rs` — each builds
  its own `EmbeddingProvider` from config, does a best-effort per-
  example embedding pass when non-noop, and emits a structured
  tracing summary (`embedded`, `errors`, `elapsed_ms`). Bins
  intentionally do NOT touch the metrics registry (would be a
  different instance from the API process's); API metrics remain
  authoritative.

Verified here (agent could not run `make`; reran locally):
  * `cargo build -p finima-api` (no features) ✓
  * `cargo build -p finima-api --features finima-categorize/sona` ✓
  * `cargo build -p finima-api --features
     finima-categorize/sona,embedder-ollama` ✓
  * `cargo build -p finima-api --features
     finima-categorize/sona,embedder-candle` ✓
  * `cargo build -p finima-api --bin bootstrap_tier2 --bin bootstrap_flows` ✓
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona,finima-analysis/sona,embedder-ollama,
     embedder-candle -- -D warnings` clean
  * `cargo clippy --workspace --all-targets -- -D warnings` (default) clean
  * `make help` / `make lint` exit 0

Phase 3 is wired end-to-end. Next up: Phase 3.5 replaces the Candle
runtime stub with a real sentence-transformer (BertModel via
candle-transformers + tokenizers + hf-hub). **No stub will survive
in the branch merged to main.**

Co-Authored-By: claude-flow <ruv@ruv.net>
Replaces the earlier compilation stub of `CandleEmbedder` with a
fully working BERT sentence-transformer embedder. The user's
constraint was explicit: no stubs in the branch merged to main, and
this commit honors it — a repo-wide grep for `stub`, `not yet wired`,
and `BackendUnavailable("candle"` in `crates/finima-embed/` returns
zero hits.

Implementation:
  * Loads the model on demand via `hf-hub` (default:
    `sentence-transformers/all-MiniLM-L6-v2`, 384-dim). Prefers
    `model.safetensors`; falls back to `pytorch_model.bin`.
  * Tokenizes with `tokenizers` (HuggingFace), builds
    `input_ids` + `token_type_ids` + `attention_mask` tensors on the
    selected device.
  * `BertModel::forward` → last hidden state → attention-mask-weighted
    mean-pool → L2 normalize → `Vec<f32>` of length `dim`.
  * Async `embed` uses `tokio::task::spawn_blocking` because Candle
    forward passes are blocking. `Arc`-wrapped model + tokenizer so
    the closure captures cheaply.
  * `CandleDevice::{Cpu, Metal, Cuda, Auto}` — `Auto` picks CUDA if
    the `candle-cuda` feature is compiled, else Metal, else CPU.
    `candle-metal` / `candle-cuda` features propagate through to
    `candle-core/nn/transformers`.
  * Back-compat: the preserved `CandleEmbedder::new(model_id, dim)
    -> Self` shim panics on load failure so existing `state.rs` and
    the two bootstrap bins compile unchanged. `load()` /
    `load_on(device)` are the preferred Result-returning entry
    points; future callers should prefer them.

Dependency pins (chosen to avoid lockfile churn — candle 0.10.2 was
already transitive via mistralrs):
  candle-core           0.10
  candle-nn             0.10
  candle-transformers   0.10 (net-new; +fancy-regex 0.17)
  hf-hub                0.4  (ureq)
  tokenizers            0.21 (onig)
`mistralrs` was dropped from `finima-embed/Cargo.toml` — wrong model
family for sentence embeddings.

Verified:
  * `cargo build -p finima-embed` (default) ✓
  * `cargo build -p finima-embed --features candle` ✓
  * `cargo build -p finima-embed --features candle-metal` ✓
    (Metal kernels compile on Apple Silicon)
  * `cargo test -p finima-embed --features candle --lib`
    — 2 passed, 1 `#[ignore]`d. The ignored test is a real
    end-to-end run: `cargo test --features candle -- --ignored`
    downloads MiniLM and asserts 384-dim unit-norm output.
  * `cargo clippy -p finima-embed --features candle --all-targets
     -- -D warnings` clean
  * `cargo build -p finima-api --features
     finima-categorize/sona,embedder-candle` ✓
  * `cargo clippy --workspace --all-targets --features
     finima-categorize/sona,finima-analysis/sona,
     embedder-ollama,embedder-candle -- -D warnings` clean
  * `make lint` exit 0

**Phase 3 (3.A + 3.B + 3.C + 3.5) is complete** with three production-
ready embedder backends — `none` (NoopEmbedder), `ollama` (HTTP),
`candle` (local BertModel via Candle). No external / paid providers,
matching the user's explicit constraint. No stubs remain.

Next: Phase 4 (cleanup, ADR-017 → Accepted, draft PR, rollout plan).

Co-Authored-By: claude-flow <ruv@ruv.net>
Phase 4 — documentation round.

* `docs/ADRs/ADR-012-tiered-categorization-engine.md` — Status flipped
  to Accepted. New `## Implementation Status` block enumerates the
  shipped Tier 2 backends (Jaccard default, RuVector under `sona`),
  the `finima-embed` abstraction (none / ollama / candle), migrations
  025+026, the `bootstrap_tier2` bin, and the new
  `POST /api/categorize/with-vector` handler. Tier 3 (LLM) is
  explicitly out of scope here and covered by the existing `LLM=`
  family.

* `docs/ADRs/ADR-017-sona-enhanced-flow-detection.md` — Status flipped
  to Accepted. `## Implementation Status` block covers
  `FlowPatternMatcher` trait + `RuVectorPatternMatcher` (per-source-
  account scoping so patterns never cross accounts), confirm/dismiss
  feedback in `handlers/flows.rs::update_flow`, migration 027,
  shared `finima-embed` abstraction, `bootstrap_flows` bin. LoRA
  adaptation is documented as deferred with a pointer to Phase 0d
  for why (REINFORCE gradient is zero under single-step trajectories
  and LoRA weights don't survive restart).

* `docs/ADRs/README.md` — index now shows both ADRs as Accepted.

* `docs/guides/embedder.md` (new) — operator guide: backend
  selection table, compile-time vs runtime toggles, the `EMBEDDER=`
  Makefile matrix, YAML layout from `config/categorize.yaml`,
  dimension-matching reference for MiniLM / nomic / mxbai, bootstrap
  invocation, Ollama + Candle prerequisites, full metric list,
  cross-links.

* `docs/guides/categorization.md` — updated the "Tier 2 (Planned)"
  section to "(Available)" with a one-paragraph pointer to the
  embedder guide; Tier 2 coverage cell no longer marked "planned".

Also included: `cargo fmt` auto-reorderings across
`finima-embed`, `finima-analysis`, `finima-categorize`, `finima-api`,
and a tiny yaml format tweak in `config/categorize.yaml`. No
semantic changes.

Verified:
  * `markdownlint-cli2` on all 5 modified/new doc files — 0 errors.
  * `make lint` exit 0.

Next: open the draft PR against `main`.

Co-Authored-By: claude-flow <ruv@ruv.net>
@pacphi
pacphi marked this pull request as ready for review April 19, 2026 17:57
@pacphi
pacphi merged commit 6dd1f34 into main Apr 19, 2026
11 checks passed
@pacphi
pacphi deleted the feat/tier2-ruvector branch April 19, 2026 18:00
pacphi added a commit that referenced this pull request Jul 26, 2026
…out enablement (#100)

* feat(tier2): observability gauges, E2E persistence test, staging rollout enablement

Closes #32, #33, #31 (all PR #30 follow-ups) via three independent
pieces of work landed together on one branch.

#32 - wire tier2_index_size / flow_pattern_index_size gauges
Both gauges were defined and registered but never .set() anywhere,
always reporting 0. SemanticCategorizer::index_size() and
FlowPatternMatcher::pattern_count() already existed for exactly this
purpose. AppState::set_metrics seeds both from the live in-memory
stores at registry-install time; update_flow's confirm branch updates
flow_pattern_index_size after each store_pattern call, mirroring the
existing flow_patterns_confirmed_total.inc() pattern. Not wired into
bootstrap_tier2/bootstrap_flows: neither builds a MetricsRegistry or
exposes /metrics, so a gauge set there would be inert; Tier2 also has
no runtime learn() path (query-only), so bootstrap is its only real
mutation point and setting a gauge nothing scrapes is out of scope.

#33 - E2E integration test for Tier2 + flow-pattern persistence
New crates/finima-api/tests/tier2_flow_persistence_test.rs (3 tests,
one sona-gated) against the existing docker-compose.test.yml Postgres
harness, following auth_test.rs's established convention: finima-api
is a binary crate with no lib.rs, so tests reconstruct routes/repo
calls locally rather than importing the real handlers. Covers
bootstrap_semantic -> embedding_index row count, POST
/api/categorize/with-vector against a deterministic fake vector, and
PUT /api/flows/:id confirm -> resolve_one_sided_flows_with_vectors
round-trip. common/mod.rs gained purely additive repo accessors
(flow_pattern_repo, embedding_index_repo, etc.) - nothing existing was
changed.

#31 - safe non-prod ruvector rollout enablement
New config/staging.yaml overlay (loaded via APP_ENV=staging) sets
categorize.tier2.backend=ruvector + embedder.backend=candle without
touching categorize.yaml's production-facing jaccard default. candle
was chosen over ollama because MiniLM-L6-v2's native 384-dim output
already matches the base tier2.dim/embedder.dim, whereas ollama's
configured nomic-embed-text model is 768-dim and would need two more
overrides. docs/guides/embedder.md gains a full rollout runbook,
including the build/deploy/bootstrap/observe steps and a callout for
the one real footgun: Tier2Config::resolved_backend() silently
downgrades ruvector back to jaccard with only a tracing::warn! if the
binary wasn't compiled with --features sona - there's no hard failure,
so APP_ENV=staging alone isn't sufficient. The issue's own week-long
staging observation and lift write-up are explicitly out of scope for
this change (no reachable staging environment, no way to fast-forward
a week of traffic) and are called out as the operational follow-up.

Verification (rustc 1.97.1, matching CI's dtolnay/rust-toolchain@stable):
cargo fmt --all -- --check, cargo clippy --workspace --all-targets --
-D warnings, and cargo test --workspace (29 test binaries, 0 failures,
including the new tier2_flow_persistence_test) all pass. cargo-audit
clean, Cargo.lock unchanged - no new dependencies.

* fix(tier2): remediate QE-Court findings on gauge accuracy, candle panic, test coverage

An adversarial review (brutal-honesty-review + qe-court: defense, 4 blind
prosecutors, a cross-vendor codex review, a blind kill round, and a
cross-vendor jury) rendered REMAND with 6 MAJOR charges on the previous
commit. This fixes 5 of them fully and documents the honest residual
gap on the 6th rather than papering over it.

Fixed:

- flow_pattern_index_size flip-flopped to 0 on any embed failure or
  noop-embedder request, because each of the 3 (now 4, including
  dismiss) call sites read .pattern_count() off whichever locally
  write-locked matcher fired for that specific request - and
  state.rs deliberately constructs the flow_matcher trait-object
  handle as a permanently-inert StubPatternMatcher even in a
  successful sona+RuVector build. Added resolve_flow_pattern_index_size,
  a single authoritative helper that always prefers the real
  flow_matcher_ruvector count when available, so one failed embed no
  longer clobbers a correct prior value with last-write-wins Gauge::set.

- tier2_index_size is architecturally guaranteed to read 0 forever:
  set once at boot from a freshly-empty store, no runtime learn()
  path exists in the live server, and bootstrap_tier2 populates a
  separate process's throwaway store. No code fix is possible without
  the Phase 2 embedding_index-persistence work this codebase's own
  comments already say isn't done. Fixed with honesty instead: the
  doc comments (state.rs, metrics.rs HELP text) and the runbook now
  say plainly that this is a boot-time snapshot, not a live gauge,
  and point at what's actually observable during a rollout
  (tier2_queries_total/tier2_search_latency_seconds,
  flow_pattern_index_size).

- docs/guides/embedder.md's runbook step 3 (bootstrap commands) omitted
  the --features sona,embedder-candle flags step 1 told the operator
  to build with - would silently downgrade the backend. Fixed. Also
  removed the bogus `-- --version` smoke-check (main.rs has no CLI
  arg parsing; it attempts a real server startup instead) and the
  stale "if #32 has landed" hedge.

- build_embedder's "candle" arm called the panicking
  CandleEmbedder::new() instead of the Result-returning
  CandleEmbedder::load() the same crate documents for exactly this
  purpose - an HF Hub outage/rate-limit/restricted-egress staging
  network would crash the whole process before it could bind a port
  or serve /health. Now uses load() with a tracing::warn! + NoopEmbedder
  fallback, matching the function's own "safe fallback" doc comment.
  (bootstrap_tier2.rs/bootstrap_flows.rs still call the panicking
  constructor directly - lower-severity, since those are one-shot CLI
  tools, not the always-on server - and are unchanged here.)

- Test 2 was framed as testing the vector-aware categorize endpoint
  but was hardcoded to the Jaccard store with no sona branch at all,
  with or without --features sona. Added a real
  #[cfg(feature = "sona")] dispatch to the actual
  finima_categorize::tier2::ruvector_store::categorize_with_vector
  function, and a new test that seeds mutually-exclusive
  Jaccard-vs-RuVector answers so it can only pass if the RuVector
  path genuinely ran - verified by temporarily sabotaging that
  function and confirming the test fails, then reverting.

Not fixed, disclosed honestly rather than claimed: the deeper charge
that tier2_flow_persistence_test.rs provides no regression protection
for the real production handler code (AppState::set_metrics,
handlers::flows::update_flow) remains genuinely true. Re-running the
exact scoped cargo-mutants commands the original review used shows
zero improvement - state.rs still 0/27 caught, flows.rs update_flow
still 0/3 - because finima-api is a binary crate with no lib.rs, so no
integration test in this crate can ever call the real handler/AppState
code; every test (including this one, and the pre-existing
auth_test.rs/authorization_test.rs) exercises a hand-rolled local
reimplementation instead. This is a genuine architectural gap, not a
test-writing gap, and closing it needs a deliberately-scoped
finima-api lib.rs extraction - out of scope for this remediation pass
given the blast radius (it would touch every existing integration
test), flagged as recommended follow-up rather than attempted here
under time pressure. tier2_flow_persistence_test.rs's own header
comment now states this limitation explicitly instead of the
previous commit's overclaiming "exercises real production code paths
end-to-end" language.

Verification (rustc 1.97.1, matching CI): cargo fmt --all -- --check,
cargo clippy --workspace --all-targets -- -D warnings, and
cargo test --workspace (29 test binaries, 0 failures, both default and
--features sona, including the 2 new/modified tier2_flow_persistence_test
cases) all pass. cargo-audit clean, no new dependencies.

* fix(bootstrap): close the residual CandleEmbedder panic in bootstrap bins

Same footgun as the one just fixed in state.rs's build_embedder, in
the two nearly-identical duplicated build_embedder_for_bin copies
(duplicated per their own doc comments, since these bins run without
pulling in the full AppState wiring): both called the panicking
CandleEmbedder::new() instead of the Result-returning load() the
crate documents for exactly this purpose. An HF Hub outage/rate-limit
during a bootstrap run would abort the whole process instead of
degrading to NoopEmbedder like every other backend-selection arm in
these same functions already does.

Flagged as a residual in the prior remediation commit rather than
fixed there, since it's outside a live-server request path (lower
severity - these are one-shot CLI tools) - closing it now since the
fix is the exact same proven pattern, applied identically to both
files.

Verified: cargo fmt --all -- --check, cargo clippy --workspace
--all-targets -- -D warnings (CI's actual gate) both clean under
rustc 1.97.1; cargo build -p finima-api --features sona,embedder-candle
--bin bootstrap_tier2 --bin bootstrap_flows compiles cleanly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant