Skip to content

feat(memory): embedding foundation — ONNX runtime, provisioning, embed-on-write, backfill (memory-core-redesign slice 2) - #1577

Merged
Aaronontheweb merged 10 commits into
netclaw-dev:feature/memory-embeddingsfrom
Aaronontheweb:redesign/slice-2-embedding-foundation
Jul 5, 2026
Merged

feat(memory): embedding foundation — ONNX runtime, provisioning, embed-on-write, backfill (memory-core-redesign slice 2)#1577
Aaronontheweb merged 10 commits into
netclaw-dev:feature/memory-embeddingsfrom
Aaronontheweb:redesign/slice-2-embedding-foundation

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Branch strategy: this PR targets feature/memory-embeddings, not dev. The embedding stack (slices 2, 3, 4, 6) stages there so dev releases don't carry the OnnxRuntime native payload while the feature is dormant; the feature branch merges to dev as one deliberate integration when hybrid recall makes the weight worth shipping (paired with the enable-flip UX: release-notes callout + init-wizard choice). Slice 5 (taxonomy/tool lessons) is embedding-independent and will target dev directly.

What this is

Slice 2 of the memory-core-redesign OpenSpec change (tasks 2.1–2.15, all checked): the complete embedding foundation. Ships dark by designMemory.Embeddings.Enabled defaults to false; nothing reads vectors until slices 3 (write-side nominator) and 4 (hybrid recall), so this slice is zero-behavior-risk per the migration plan. The enable-flip happens consciously in those slices.

What's inside

Library (src/Netclaw.Embeddings, new)

  • OnnxMemoryEmbedder: in-process CPU inference (Microsoft.ML.OnnxRuntime + FastBertTokenizer), CLS pooling verified against both model cards and end-to-end against the real model, bounded concurrency.
  • EmbeddingModelProvisioner: pinned in-code allowlist — snowflake-arctic-embed-m (primary) and mxbai-embed-large-v1 (fallback), URLs pinned to upstream commit shas, SHA-256s verified against HuggingFace LFS object ids by actually downloading the artifacts. Atomic download, hash-verify-before-load, unknown-id rejection. Arbitrary URLs impossible.
  • IMemoryEmbedder seam lives in Netclaw.Actors (consumer-defined; Actors does NOT reference Embeddings or OnnxRuntime) + UnavailableMemoryEmbedder degraded stub that throws with remediation rather than returning garbage.

Store + index (Netclaw.Actors)

  • memory_embeddings table keyed (item_id, model_id) + content hash: model swap = new rows + forced backfill, zero rewrite of the documents table; backfill state is derived, never a progress table; tombstones delete embedding rows.
  • MemoryVectorIndex: brute-force TensorPrimitives cosine over a flat matrix, version-invalidated (~1.8MB at current corpus scale).
  • MemoryContentHasher: normalized title+body SHA-256; hash-unchanged re-embeds are free.

Daemon + CLI integration

  • EmbeddingWarmupHostedService: provision-or-degrade at startup, warm-up inference, gap-repair sweep (crash between write and embed self-heals). The daemon NEVER fails startup over embeddings — degraded is loud, not fatal: error-level memory_embedding_unavailable, daemon status Memory.Embeddings.Status: degraded, doctor Error.
  • Embed-on-write via one shared MemoryEmbedOnWriteCoordinator with two thin call sites (there is no single physical commit point — the two store batch-apply methods assign document ids inside their transactions, so both now return write results and feed the one coordinator). Embedding failure never fails a memory write: vectors are derived data.
  • netclaw memory backfill-embeddings [--force] (new command group), live-daemon-safe.
  • MemoryEmbeddingDoctorCheck: missing/hash-invalid model → Error; incomplete coverage or mixed-model corpus → Warning (mixed models invalidate similarity thresholds); coverage summary otherwise.
  • Config Memory.Embeddings { Enabled=false, ModelId, AutoDownload } + schema sync; skills synced (netclaw-memory 1.8.0, netclaw-operations 2.25.0); ARM64 release leg verifies published binaries are genuinely aarch64 (cross-compiled; natives are bundled single-file so ELF-arch check is the maximal cheap verification).

⚠️ Measured latency finding (task 2.13 — gates Slice 4)

Short-query embedding via the production path on the reference i9-9900K (contended, live daemon):

corpus p50 p95 mean
short (10–16 tok) 281.0ms 315.2ms 285.2ms
medium (~178 tok) 273.7ms 298.3ms 276.8ms
doc (~442 tok) 274.9ms 293.6ms 276.5ms

The Slice-4 150ms query-embedding sub-budget does not hold (p95 2.1× over). Root cause is diagnostic: the embedder pads every input to a fixed 512 tokens, so the forward pass is length-invariant — a 14-token query costs the same as a 450-token document. Mitigations ranked in design.md (now updated with the measured table): dynamic sequence length (highest leverage, O(n²) attention), int8 quantization, budget adjustment with the already-designed loud lexical fallback. A dynamic-length experiment is running before Slice 4's design is finalized. Cold load: 1.07s (LoadAsync+first embed) — the warmup service exists for exactly this.

Validation

Adds the src/Netclaw.Embeddings project (Microsoft.ML.OnnxRuntime CPU EP,
FastBertTokenizer, System.Numerics.Tensors), referenced by nothing yet —
daemon/CLI wiring is Stage B.

- OnnxMemoryEmbedder: single InferenceSession (IntraOpNumThreads=4),
  BoundedConcurrencyGate (default max 2 concurrent inferences, peak
  concurrency observable for tests), FastBertTokenizer WordPiece
  tokenization truncated to 512 tokens. Feeds only the input names the
  loaded ONNX graph declares rather than hardcoding the 3-input BERT
  signature. CLS-token pooling (last_hidden_state[:, 0, :]) + L2
  normalization — verified against both allowlisted models' model cards
  (snowflake-arctic-embed-m: "use the CLS token"; mxbai-embed-large-v1:
  "works really well with cls pooling (default)").
- EmbeddingModelProvisioner: pinned in-code allowlist (model id -> URL,
  SHA-256, byte size, dimensions) injected as a required dependency (not
  a hardcoded internal) so tests can supply a localhost-pointed allowlist
  instead of ever reaching the real HuggingFace URLs. Atomic
  temp-file-then-rename download, byte-size + SHA-256 verification before
  the destination file is ever created, unknown-id rejection listing the
  allowlist. Allowlist entries (URLs pinned to a specific upstream commit,
  not `main`): snowflake-arctic-embed-m (768 dims, plain fp32 model.onnx,
  ~416 MB) and mxbai-embed-large-v1 fallback (1024 dims, ~1.27 GB fp32).

Tests (Netclaw.Embeddings.Tests, no network):
- Tiny fixture ONNX graph + WordPiece vocab (generated by
  Fixtures/generate_fixture_model.py, committed with a header comment
  explaining the graph shape and regeneration steps) exercise
  OnnxMemoryEmbedder end-to-end: deterministic output, L2-normalized,
  content-sensitive (attention-masked mean pooling reported at the CLS
  position so a content-blind bug would be caught), batch order
  preservation.
- BoundedConcurrencyGate tested in isolation against a controlled fake
  delayed workload (Task.Delay lives in the fake, not in test
  orchestration) proving the concurrency bound is actually enforced.
- EmbeddingModelProvisioner tested against a local HttpListener fixture:
  hash-mismatch and byte-size-mismatch rejection with no leftover temp
  files, unknown-id rejection listing the allowlist, successful
  provision leaves exactly the two expected files.

opsx: memory-core-redesign slice 2
…eddings schema

- IMemoryEmbedder (+ UnavailableMemoryEmbedder degraded stub) in
  Netclaw.Actors/Memory so actor code carries no OnnxRuntime dependency;
  Netclaw.Embeddings implements the interface, never the reverse.
  UnavailableMemoryEmbedder throws InvalidOperationException with
  remediation text on Embed*Async rather than returning a garbage vector.
- MemoryContentHasher: SHA-256 over normalized title+body, reusing
  CurationRulesEvaluator.NormalizeForContainment (promoted from private to
  internal) rather than a second hand-rolled normalizer, so curation's
  destructive-update guard and the embedding re-embed skip can't quietly
  disagree about what counts as changed content.
- MemoryVectorIndex: per-model flat float[] + parallel id/kind arrays
  bundled into an immutable snapshot (no torn reads), TopK via
  System.Numerics.Tensors.TensorPrimitives.CosineSimilarity with a
  minCosine floor, reload gated on SQLiteMemoryStore.EmbeddingDataVersion
  so unchanged turns pay no reload cost.
- SQLiteMemoryStore: memory_embeddings(item_id, item_kind, model_id,
  content_hash, dims, vector BLOB, created_at) DDL in the existing
  idempotent InitializeAsync; UpsertEmbeddingAsync (hash-skip: no write
  and no EmbeddingDataVersion bump when the content hash is unchanged,
  float32 LE blob); GetEmbeddingsForModelAsync (thin query for the vector
  index to consume — the design's FindNearestByEmbeddingAsync, renamed
  per plan); GetEmbeddingCoverageAsync (total recallable docs,
  embedded-current-hash count, other-model count) for the coverage
  diagnostics spec requirement; TombstoneDocumentAsync extended to delete
  the tombstoned document's embedding rows in the same transaction and
  bump the version counter, since vectors are derived data that must not
  keep surfacing a dead document as a kNN neighbor.

No production code path calls any of this yet (embed-on-write, the vector
index's runtime wiring, and the doctor/status degradation surfaces are
Stage B) — this slice writes vectors, nothing reads them, zero behavior
risk per the design's migration plan.

opsx: memory-core-redesign slice 2
… slice 2)

Task 2.12 (tests) stays unchecked — its warmup/gap-repair/doctor-facing
scenarios land with Stage B daemon wiring; the store/index/hasher/
provisioner/embedder subset testable at this layer is covered.
…seams, config (opsx: memory-core-redesign slice 2, tasks 2.7/2.8/2.11)

- MemoryEmbedderHolder: mutable holder the warmup hosted service populates
  (hosted-service startup order vs construction-time DI documented on the type)
- MemoryEmbedOnWriteCoordinator: single embed-on-write hook both curation
  pipelines call post-commit; embedding failures never fail the memory write
  (vectors are derived data, D3)
- SQLiteMemoryStore: ApplyInlineCurationBatchAsync/ApplyCurationBatchAsync now
  return the written document rows (the post-commit ids+content the coordinator
  needs); GetDocumentsNeedingEmbeddingAsync derives gap-repair/backfill state
  (never a progress table); UpsertEmbeddingAsync reports wrote-vs-skipped
- MemoryCurationActor + MemoryCurationWorkerService callers embed after commit
- Memory.Embeddings config { Enabled=false (deliberate staging, flipped in
  Slice 3/4), ModelId=snowflake-arctic-embed-m, AutoDownload=true } + schema
  sync with defaults; NetclawPaths.ModelsDirectory + EmbeddingModelDirectory
- DaemonRuntimeStatus.Embeddings wire type (ok/degraded/disabled)
- EmbeddingModelProvisioner: skip-if-valid local copy (no network on restart)
  + TryLoadVerifiedAsync for AutoDownload=false paths
…urface (opsx: memory-core-redesign slice 2, tasks 2.7/2.8/2.10)

- EmbeddingWarmupHostedService: provision-or-degrade at startup (AutoDownload
  gates the network path entirely — even to repair a corrupt local copy), one
  warm-up inference, then a batched (16, yielding) gap-repair sweep over
  documents missing a current-model/current-hash embedding
- ANY failure => UnavailableMemoryEmbedder + error-level
  memory_embedding_unavailable log; daemon NEVER fails startup on embeddings
- DI: holder starts as Unavailable stub; warmup populates it; SessionMemoryServices
  carries it to the inline curation actor
- MemoryCurationWorkerService embeds written docs post-commit (task 2.8's
  second pipeline call site)
- DaemonRuntimeStatusService reports embeddings: ok/degraded/disabled with
  modelId under the Memory status block
…k (opsx: memory-core-redesign slice 2, tasks 2.9/2.10)

- New 'netclaw memory' command group (offline, direct SQLite/model-file
  access) with backfill-embeddings [--force]: provisions if needed (clear
  error when AutoDownload=false and model missing), embeds in batches of 16
  with progress output, final embedded/skipped-hash-unchanged/failed summary;
  safe against a live daemon (WAL + per-item upserts whose hash check
  re-queries at call time)
- MemoryEmbeddingDoctorCheck: Error when Enabled but model missing/hash-invalid;
  Warning on missing current-model embeddings (count) or mixed-model corpus
  (recommends --force backfill); Pass with coverage summary; Pass when disabled
- Allowlist is an injected dependency on both (same seam as the provisioner)
  so tests use the tiny fixture model — no network in tests
- Schema round-trip tests for the Memory.Embeddings config section
…2.12 complete (memory-core-redesign slice 2)

design.md D2 said 'snowflake-arctic-embed 137M int8' — Stage A shipped the
~110M-param arctic-embed-m fp32 ONNX artifact pinned by hash; int8 is noted
as a future optimization, not what the allowlist points at.
…sx) design.md

Add tools/embed-latency-bench (standalone console, kept out of Netclaw.slnx):
loads the production OnnxMemoryEmbedder path (hash-verified snowflake-arctic-embed-m,
same pooling/threading/concurrency-gate config the daemon uses) and times batch=1
EmbedAsync calls across short-query/medium/doc-length corpora (20 warmup + 200 timed
iterations each), plus cold-load and a concurrency=2 pass.

Measured on the i9-9900K reference box (8 logical cores, contended: load avg 2.0-3.6,
~11/15 GiB RAM in use, live daemon running): short-query p50 281ms / p95 315ms -
statistically indistinguishable from doc-length (p50 275ms / p95 294ms) because
OnnxMemoryEmbedder pads every input to a fixed 512 tokens regardless of actual
length, so the fixed-size fp32 forward pass dominates latency, not tokenization.

Resolves memory-core-redesign task 2.13 and its design.md open question: the
150ms query-embedding sub-budget does NOT hold on this hardware (p95 ~2.1x over
budget, margin ~-165ms). Updates D6, the Risks/Trade-offs entry, and the Open
Questions table with the measured numbers and verdict; corrects stale "ONNX int8"
wording to match the D2-shipped fp32 reality (int8 remains a deferred optimization).
Highest-leverage unexplored mitigation: a query-specific max-length well below 512,
not int8 quantization.
Extends tools/embed-latency-bench with a bench-only parallel code path
(OnnxMemoryEmbedder production code untouched) that:

- inspects InferenceSession.InputMetadata to confirm the ONNX graph's
  sequence axis is symbolic (dynamic), not fixed
- runs the same short/medium/doc corpora padded to actual tokenized
  length (bucket-of-8 rounding) instead of fixed 512, same 20
  warmup / 200 timed / batch=1 / Release protocol
- cross-checks correctness: cosine similarity between fixed-512 and
  dynamic-length embeddings for 10 fixed sentences
- records load average before/after for honest contention context

Measured on the reference box: short-query p50 19.0ms / p95 20.9ms
(vs 281.9ms / 310.5ms fixed-512) with 1.000000 cosine parity across
all 10 sentences. Well under the 150ms Slice 4 sub-budget.

Updates openspec/changes/memory-core-redesign/design.md (D6, Risks,
Open Questions) with the measured numbers and the decision to adopt
dynamic sequence length as the Slice 4 mitigation.
@Aaronontheweb
Aaronontheweb changed the base branch from dev to feature/memory-embeddings July 5, 2026 17:34
@Aaronontheweb
Aaronontheweb merged commit 08a7bb5 into netclaw-dev:feature/memory-embeddings Jul 5, 2026
14 of 15 checks passed
@Aaronontheweb
Aaronontheweb deleted the redesign/slice-2-embedding-foundation branch July 5, 2026 17:39
Aaronontheweb added a commit that referenced this pull request Jul 8, 2026
#1603)

The memory-core-redesign slice 2 (#1577) added the `netclaw memory`
CLI command but never updated the approved `help` screenshot baseline,
so Screenshot Regression (Linux) has been red on every push to
feature/memory-embeddings since (verified via gh run list against
feature/memory-embeddings: 3/3 recent pushes failed this check).

Reviewed the captured diff locally: the only change is the new
  memory              Manage cross-session memory (embeddings backfill, offline)
line shifting everything below it down one row. No other screenshot
frame changed.
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