feat: Tier 2 semantic categorization + SONA-enhanced flow detection - #30
Merged
Conversation
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
marked this pull request as ready for review
April 19, 2026 17:57
This was referenced Apr 19, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Delivers ADR-012 (Tier 2 of the categorization cascade) and ADR-017 (SONA-enhanced flow detection) across four phases on
feat/tier2-ruvector:CascadeEngineTier 2 wiring;Tier2Config,RuVectorEmbeddingStore,bootstrap_semanticbehind thesonafeature with Jaccard fallback.bootstrap_tier2bin +POST /api/categorize/with-vector.FlowPatternMatchertrait +RuVectorPatternMatcher(HNSW keyed per-source-account so patterns never leak across accounts); confirm/dismiss feedback with half-life decay on dismiss inhandlers/flows.rs::update_flow.bootstrap_flowsbin.finima-embedcrate withEmbeddingProvidertrait and three real backends:NoopEmbedder,OllamaEmbedder,CandleEmbedder(candle-metal/candle-cudaacceleration). No stubs survive — Phase 3.5 replaced the Candle compilation shim with a real BertModel loader.EMBEDDER=Makefile flag mirrorsLLM=. Prometheus metrics for Tier 2, flow patterns, and bootstrap.docs/guides/embedder.md; categorization guide refreshed.Phase 0 research (already landed on
mainvia #22)docs/spikes/ruvector-phase0.md— full findings from the four research spikes (API probe,ruvector-sonavalidation, persistence round-trip, MicroLoRA training-path verification). Ships as the foundation every downstream decision in this PR cites.Tests
finima-categorize— 68 tests withsona; Jaccard + RuVector store unit tests, engine integration tests, bootstrap counters.finima-analysis— 62 tests withsona;RuVectorPatternMatcherconstruction + 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 behindcargo test --features candle -- --ignoredto 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 buildwith no flags compiles a binary withbackend: jaccardfor Tier 2 andbackend: nonefor the embedder — no RuVector, no Ollama, no Candle in the dependency graph. Operators enable the stack with one flag:Migrations
025_embedding_index.sql— Tier 2 persisted embeddings + indices026_portfolio_sona_state.sql— ReasoningBank snapshot column onportfolios027_flow_patterns_embedding.sql—description_embedding BYTEA+embedding_dim INTEGERonflow_patternsReviewer checklist
cargo build) — no regressionssonafeature build (cargo build --features finima-categorize/sona,finima-analysis/sona)cargo build --features embedder-ollama,embedder-candle)make lintexit 0References
docs/ADRs/ADR-012-tiered-categorization-engine.mddocs/ADRs/ADR-017-sona-enhanced-flow-detection.mddocs/guides/embedder.mddocs/spikes/ruvector-phase0.md🤖 Generated with claude-flow