Commit Graph
9 Commits
Author SHA1 Message Date
ZacharyZhang-NY 9145a23f18 Require a token for writes and quarantine what nobody vouches for
Sixteen mutating routes now refuse a caller who cannot present the token the
server started with; queries stay open, because a question is not a write. The
environment variable is ELY_EYE_AUTH_TOKEN, following the repo's env_prefix
convention — the shorter name I reached for first returned 403 where the smoke
expected 401, and the server printed the reason.

Words from a stranger are quarantined on the way in: an HTTP upload is
third_party by definition, and a sentence carrying an instruction-shaped phrase
is quarantined whoever wrote it. A quarantined atom stays findable and stays out
of the compiled context until a second, live, unquarantined source agrees with
its rarest words. Corroboration is computed at read time because it is a
property of the corpus now, not of the moment a memory was written; it is
term-level, and the docstring says so.

forget now erases the source row and the compiled contexts beside the atoms, so
a forgotten memory leaves nothing that can still speak. The semantic cache key
carries a store revision, which fixes a real staleness: a context compiled
before new evidence arrived kept its old quarantine decision forever.

bench.audit --product still pointed at evals.py, which the p1-6 split moved into
backend/src/ely_eye/proofs/. It failed fast, but the anti-self-justification
rule had quietly stopped auditing the product; it now walks the package, and a
test pins that the package exists and holds no re-roll.

Gates: pytest 306 passed; ruff clean; mypy cold cache 24 errors in 9 files, the
recorded baseline, net zero; pyright 0 errors on the 23 backend files and the 2
bench files; go build/vet/test and gofmt clean; npm run build 221.39 kB with
eslint exit 0; bench/filesize_check clean; bench.audit --product clean over 31
harness files; scripts/mcp_write_smoke.sh PASS with the gate live across two
servers and the MCP child.
2026-10-08 23:05:15 -04:00
ZacharyZhang-NY cd9851a12c Keep the vectors beside the atoms instead of rebuilding them per query
The dense leg used to be a pair of files next to the database: a full .npy
re-encoded from every atom, re-normalized on every query, and stale the moment
anything was ingested. It is now a vec0 table inside the same SQLite file,
written in the same transaction as the atom it describes, so a memory is
queryable the instant it is remembered.

- vector_index.py maintains the table; the writer inserts, the reader never
  rebuilds. A repair scan (ely-eye build-dense-index) catches what a crash hid,
  and it is not on the search path.
- Ranking stays in the engine; every boundary (closed, retired, not yet true,
  another holder's private memory, the mounted cartridge) is checked once against
  the atoms table afterwards. A visibility subquery inside the KNN cost 48.9s on
  153,857 vectors; the same check over 200 candidates costs 0.2ms. When the
  boundaries reject enough candidates the ask widens once, never twice.
- The Go mirror and the web panel carry the new status fields.
- bench/dense_scale.py and bench/quant_probe.py measure the leg at corpus size;
  bench/scale_check.py now works on a copy so the frozen baseline stays frozen.

Measured on the frozen 165,973-atom corpus. bench/reports/DENSE.md, QUANT.md and
SCALE.md carry the numbers and the machine they were taken on.

Gates: 285 pytest, ruff clean, pyright 0 on the touched files, go build/vet/test
and gofmt clean, frontend build and eslint clean. mypy is 25 findings against the
parent's 24: the new one is "Skipping analyzing sqlite_vec: missing library stubs",
the same class as the two existing qwen_vl_utils lines, and the repo keeps no
type: ignore anywhere.
2026-10-08 14:05:27 -04:00
ZacharyZhang-NY 922d01202f Run the same checks on Linux that the Windows scripts run
The product could only reach a GPU through one hard-coded route: `wsl.exe -d
Ubuntu`, a device named "RTX 4090", and launch scripts that existed only as
PowerShell. On this machine `WSL_DISTRO_NAME` is already set, so the local
shell is Linux and the wsl.exe hop was unnecessary; on any other Linux host
it is impossible. Every SGLang, vLLM and KTransformers profile was reported
"missing" here even with a working 4090.

profiles.py now asks the host what it is instead of assuming: `uses_wsl()`
is false inside WSL and only true when wsl.exe is actually on PATH,
`host_shell()` runs `bash -lc` directly or through `wsl.exe -d
settings.wsl_distro`, `gpu_available()` replaced `wsl_gpu_available()`, and
`launch_script()` picks the .sh twin on a Linux host and the .ps1 on a
Windows one. The schema field `wsl_gpu` became `gpu_available` because the
old name lied on Linux.

Training no longer fails on a card name. `cuda_bf16_training()` gates on
cuda_available plus bf16_supported, which the training environment already
records, so an RTX 5090 or a 4080 passes and a non-BF16 4090 still fails.

Fourteen .sh twins cover the six launch profiles plus install, verify,
serve, download and finetune; the six WSL-hopping .ps1 files read
ELY_EYE_WSL_DISTRO instead of Ubuntu. PowerShell parsing is not verified
here because no pwsh exists on this host.

ci.yml runs pytest, ruff, go build/vet/test/gofmt, the Vite build and a
docker build-plus-curl on every push. To make that lint gate mean anything,
the 17 pre-existing ruff findings across backend/src are cleared: 14
mechanical fixes plus three probe handlers that now name the failure
families they absorb (OSError, SubprocessError, ValueError) instead of
catching Exception.

Verification: pytest 239 passed; ruff check backend/src bench clean (17 at
HEAD); mypy on the 12 touched files 14 errors, identical at HEAD; pyright on
the same files 8 errors, identical at HEAD; bash -n clean on all 15 .sh.
2026-10-08 02:12:29 -04:00
ZacharyZhang-NY 6f8764099a Make the image build from a clean clone and actually serve the interface
The Dockerfile copied PRD.md, which is not in the repository, so a clean
clone could not build at all. It also installed node and npm from Ubuntu 24
(node 18) to build a Vite 8 front end that needs node 20.19 or newer, and it
built the interface into a directory nothing served.

The build is now two stages: node:22 builds the interface, the CUDA runtime
image installs the package and carries the built files. The API serves them
when ELY_EYE_UI_DIR points at a directory that exists, and refuses to start
when it is set but empty, naming the build command. The front end already
calls /api/... on its own origin, so same-origin serving needs no client
change.
2026-10-08 01:40:06 -04:00
ZacharyZhang-NY 5289cb4b01 Give spoken words a memory with an instant attached
An audio file used to produce no memory at all: _ingest_file returned an empty
batch for any suffix it did not know, with no log and no reason. It now raises
UnsupportedFile when you name the file and records a SkippedFile when it was a
directory walk, so a refusal is visible either way.

Speech is measured, not promised. Over 210.5 seconds of real Video-MME audio,
faster-whisper-tiny on CPU int8 transcribed at 51x realtime and reached word
error rate 0.242 against community subtitles nobody here wrote (a caption track
drops words a transcript keeps, so that is an upper bound). 66 speech atoms now
carry 550 word-level timestamps, 0 words out of order and 0 starting before
their own segment. The snapshot is 78.2 MB, under the 500 MB cap, and the report
is bench/reports/ASR.md.

Two library facts were measured rather than assumed. faster-whisper's own audio
loader is broken against PyAV 19 (av.open got an unexpected keyword
'metadata_errors') and it declares av with no version bound, so asr.decode_audio
decodes with PyAV directly and hands the model a float32 array instead of
pinning an old av with no cp314 wheel. And av.Error does not exist on PyAV 19 -
the real base is av.error.FFmpegError - so that is the only thing caught and my
own ValueErrors keep their messages.

Files over the 500-line rule were split rather than excused: ingestion.py (748)
into ingestion_common / ingestion_media / ingestion_repository / ingestion, and
cli.py (544) into cli_senses plus cli_common, following the cli_transactive
register pattern. MediaIngest declares _source_record abstract, so a half-wired
host fails at construction instead of returning None.

Verification: 207 tests pass; ruff clean on the 13 touched files; mypy clean on
them, and repo-wide mypy fell from 29 errors to 24 (5 fixed, none added);
pyright reports 0 errors on the touched files.
2026-10-07 21:18:28 -04:00
ZacharyZhang-NY 03ca35763f Give wordless pictures a way to be found
A screenshot with no OCR-able words used to be a memory nothing could
reach. It now has a vector, and a question can be asked with it.

- picture space: Xenova/chinese-clip-vit-base-patch16 fp16 ONNX, 361MB,
  sha256 0fad978faa89b85f…, CPU-runnable on the onnxruntime the repo
  already ships. Text 76ms, image 29ms/image at batch 8. The ONNX contract
  was measured, not assumed: it is the fused contrastive graph, so the
  unused tower is fed a placeholder row, and the tokenizer must be
  BertTokenizer — a CLIP tokenizer emits ids past the embedding table.
- visual_index.py keeps picture vectors in their own family beside the
  text ones, keyed by atom id, and refuses to answer when the map and the
  array disagree. retrieval.py stays under 500 lines.
- a search can be asked with a picture, and when the words come up thin
  the picture ranking joins the fusion — but only then, so a text query
  never pays for the encoder.
- visual tokens are counted at 32x32 patches (migration v7): 21,325 image
  atoms went from 5,331 to 34,618 tokens, and the library from 29.9M to
  84.2M. The 100M capsule fits this 50,278-file corpus and nothing much
  larger — that is what the claim actually means.
- the vision-language describer is written and gated: with no snapshot an
  atom records "deferred: no vision-language model snapshot"; a broken
  model becomes a note on the atom instead of a dead ingest.
- forget now voids the picture index and the copy this process holds, so
  a forgotten picture stops answering from RAM.

Measured, six arms, ScreenSpot-v2 (554 icon crops, 408 whole screenshots),
bench/reports/VISUAL.md:
  icon pool      picture 0.208  words 0.175  chance 0.018
  screenshot pool words 0.384   picture 0.215
  screenshot to icon 0.108  — CLIP is a whole-image model, not a grounding
  model: it cannot point at one small icon inside a screen.
  within one screen MRR 0.908 (recall@10 is 1.000 there only because
  chance is 1.000 too, so MRR is the only honest number in that row).
ScreenSpot-v2 instructions are all English, so this run does not test the
bilingual claim and the report says so.

Gates: 199 tests, ruff clean on 17 files, mypy clean on 16 (fresh cache),
the repo's own pyright 0 errors on the 12 files touched.
2026-10-07 20:41:19 -04:00
ZacharyZhang-NY 9fe13fffb3 Make a cartridge a scope you mount, not only a bundle you export
A cartridge used to be an export: materialize it, hash it, and never let it
change behaviour again. The transactive-memory promise needs the opposite —
mounting the home cartridge must make the work cartridge's memories
unfindable, and appending to a cartridge must make a new memory findable
through it.

Cartridge scope (schema v2 -> v3):
- cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts
  (which cartridges this machine has mounted now); both rebuilt on reopen,
  because a mount is a machine-local state, not a fact about the data.
- mount / switch / unmount / append refuse a cartridge that was built for a
  different runtime, naming every differing field instead of a boolean.
- append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and
  the KV fingerprint, and keeps the cartridges row in one SQLite transaction;
  the files are written first so a failed export cannot leave a half-cartridge
  in the database.
- RetrievalService.search() now ranks inside the mounted scope by default, and
  an explicit cartridge_ids filter overrides the mount. Nothing mounted means
  the whole library, so an empty store behaves as it did before.
- forget() removes the forgotten atoms from every cartridge's membership:
  forgetting must not leave a memory retrievable through a cartridge.

KV fingerprint (kv_fingerprint.py, stored in the manifest):
- model / tokenizer / chat-template hashes, the RoPE block (factor,
  original_max_position_embeddings, mrope_interleaved, mrope_section,
  partial_rotary_factor), the adapter hash, the context length, the engine
  version, and the fingerprint format version.
- A snapshot we cannot read is recorded as unverifiable and listed in
  unverifiable_fields; a blind machine refuses to mount rather than pretending
  the runtime matches, because a wrong RoPE factor silently corrupts retrieval.

Two defects found by running the new tests, not by reading:
- atoms.parquet could not export a memory at all: pyarrow refuses a struct with
  no child field, so metadata={} failed the whole table. The six JSON columns
  are now written as JSON text and decoded by the cartridge reader.
- materialize() inserted cartridge membership before the cartridges row existed,
  so the foreign key rejected every cartridge.

api.py crossed 500 lines, so the cartridge and mount routes moved to
api_cartridges.py. That module must not use `from __future__ import
annotations`: FastAPI then resolves Depends(settings_dep) against module
globals, where the injected dependency is only a parameter of register(), and
every route starts demanding settings as a query parameter.

Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go
build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole
loop across two processes - mount narrows search, a memory written after the
upload is invisible until append_to_cartridge, unmount restores the library,
and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor:
cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining
package errors are in files this change did not touch; HEAD had 25). mypy: one
error, the pre-existing np.savez stub artifact at cartridge_assets.py:329
(HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger.
2026-10-07 16:40:27 -04:00
ZacharyZhang-NY 3d792417cf Give the write path a CLI and an MCP face
remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.

- cli: remember, supersede, forget. --holder falls back to
  settings.default_holder until the holder registry lands. Timestamps are
  parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
  warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
  writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
  stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
  through v_atoms_current, so a superseded memory stops being findable.
  holder, origin, scenario and event_time now show up in fetch_atom and
  search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
  supersede, forget, then not found.

Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
2026-10-07 15:09:17 -04:00
ZacharyZhang-NY 0ee99e271d Ely-Eye — local multimodal context operating system on Qwen3.5-9B
Real-models implementation: multimodal ingestion, Context Cartridge memory,
sparse + Qwen embedding retrieval, Cache Fabric, Qwen runtime adapters, verifier
contracts, Visual HashHop proofs, BF16 LoRA fine-tuning, and a local dashboard.
Verified by a 15-check PRD proof suite on the live model.
2026-05-28 22:08:08 -04:00