15 Commits
Author SHA1 Message Date
ZacharyZhang-NY 3b87b77b7f Measure the last key-test scenario instead of writing it off
ci / backend (push) Failing after 3m19s
ci / mcp (push) Successful in 1m1s
ci / interface (push) Successful in 20s
ci / image (push) Failing after 5m54s
Scenario 7 claimed nothing was observable. It is: compiling the same
question twice hands back the same pack and the second compile is served
from the semantic cache (cache_events layer=semantic hit=1 miss=1), so the
row is now PARTLY MEASURED with those numbers and only the time-to-first-token
half stays unmeasured. Scenario 4 gains the settling step: an unresolved
disagreement shows the newer side, and after resolve-conflicts one page shows
both readings (measured 2 hits) — settlement is a retrieval behavior, not a
deletion, which is also why my first draft of that scenario was wrong.

Gaps.md §6 now carries a verdict table with what each scenario actually
measured, and §0.5 quotes whole-suite recall (0.850 words-only over 2,928
answerable queries) instead of the 300-probe sample's 0.863, naming why the
sample reads high.

Gates: 422 passed, ruff clean, filesize OK, bash -n clean, key test 6 PASS +
1 PARTLY MEASURED with exit 0.
2026-10-10 03:35:39 -04:00
ZacharyZhang-NY eab6bd7c8c Answer from the moment a question asks about
The key test in Gaps.md §6 asks what was true last week. It could not be
answered: search_atoms joined v_atoms_current, so a superseded memory is
unreachable no matter what since/until says — those two fields bound when a
memory was true, not which moment the store answers from.

SearchFilters.as_of moves that moment. Without it the words leg repeats the
view's currency predicate verbatim; with it a memory answers when
valid_from <= as_of < valid_to, its event time has arrived, and it is inside
its retention. archived_at is ignored on purpose: archival is a storage tier,
not a claim about the world, so an archived memory still answers for the time
before it was archived. The dense leg reads the same window through
vector_index._allowed, so the two legs cannot disagree about what existed.

scripts/key_test.sh runs the seven §6 scenarios end to end against one live
store — HTTP, the Go MCP binary over stdio, and the CLI mounting cartridges.
Six pass. The seventh, "a cartridge saves compute", is reported NOT MEASURED
rather than passed: it claims time-to-first-token and a cache-hit rate, no
generator has ever run here, and there is no compile or cache route to observe
even the second half.

Gates: pytest -q 406 passed; ruff clean; filesize OK; pyright 0 on the seven
touched files; mypy cold cache Success on the same seven; key test exit 0.
2026-10-09 18:25:30 -04:00
ZacharyZhang-NY 83dfc14daa Let the server decide whose memory a caller may read
A holder is now somebody who can prove it. Schema v11 stores a sha256 of a
per-holder token beside the directory row, so a copied database cannot replay
it and the plaintext is shown exactly once. The operator token mints and
revokes those tokens; a holder token reads that holder's own memories and
nothing else; naming somebody else without the operator token is refused with
both ids in the message.

The visibility rule was leaking before this: browse had none at all, and the
search rules short-circuited to the whole library whenever no holder was
named. The rule is now written out in five copies with one parameter carried
four times, and a test compares the copies so they cannot drift — semgrep
rejects an interpolated constant, so the copies are not a choice.

Eyes carry three states on purpose. NULL means the whole library, which is
what a process that owns the store is entitled to; a holder id means that
holder; the empty string means nobody, which is what a tokenless HTTP caller
is. Making NULL mean nobody instead would have forced every internal caller —
CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting
the rule that a local process reads as its own trusted principal.

The capability card answers "can this store answer my kind of question" before
anything is sent. It is derived from what the store holds, so it cannot
promise a modality the store lacks, and it carries no memory text, so it is
safe to read before trusting the store. HTTP, CLI and one MCP tool all serve
it without a token.
2026-10-09 14:15:56 -04:00
ZacharyZhang-NY 3d131e6a7e Stop a stale server from answering for this tree
The smoke script's build fingerprint asserted on the MCP reply, but the Go
layer decodes the HTTP answer into its own struct and re-emits a missing field
as zero, so a server from before the memory tiers still passed. Point the
fingerprint at the Python answer instead: /api/memory/working answers 404 on
4c6af87 and 200 here, both measured by starting each build myself.

Record the miss side of the rename rule. Whether a bare number renames its
subject depends on that memory's own sentence, so 「温度」 and 「温度24」 never
compare and an update goes unnoticed; both memories stay on the answer page,
so the miss costs a notice rather than a wrong number. Pin it with a test and
keep the mechanism, since comparing an unrenamed subject as a fallback would
put room numbers back in the ring as readings.

Correct the direction of the rule in the record: the rename slot is the token
after the subject word, not before it. A leading number becomes the subject
rather than fusing forward, which 「3 room holds 22 degrees」 shows by taking
「3」 as its subject.

门禁:pytest 384 passed;六文件 83(conflicts 27);三文件 35;ruff clean;
filesize clean;audit 32/2/6 clean;mypy 冷缓存 24 errors / 9 files(净 0);
pyright 0;go 全套 + gofmt 空;npm 产物未变;smoke PASS。
2026-10-09 12:54:06 -04:00
ZacharyZhang-NY 11e31eb7e3 Read a short subject's only number as its reading
A two-character Chinese subject — 温度, 室温, 设定, 预算 — is the common
way to state a reading, and the rename rule was swallowing its number:
「温度 22 度」 became a thing called 温度22 with nothing left to report, so
the lane recorded no conflict at all and no later memory could disagree
with it. A bare number written onto the subject is a name only while the
memory still has another number waiting behind it; with nothing after it,
it is the reading. Measured across 33 pairs: 6 change — five missed
conflicts now recorded, plus one new disclosed boundary (「Room 3 is big」
and 「Room 4 is big」 read as one quantity that moved, because a label
cannot tell an identifier from a reading). Everything else is byte-equal.

Also corrected: a memory that is only a number does have a subject — the
number itself — so 「22 度」 and 「24 度」 record nothing because they name
two different things, not because the store found nothing to compare.

The smoke script now refuses an answer from a server that predates the
memory tiers: it requires recall_count in the remember response, a field
the pre-p2-3 Go build never emits.
2026-10-09 11:44:29 -04:00
ZacharyZhang-NY dc9cfb5a30 Give memories a working tier, a long-term tier, and a record of what disagrees
A memory used to have one state: present. A session could not be reloaded
without searching, nothing separated what a person said today from what the
store ingested a year ago, and two statements that disagree about a number
were both handed to the reader as if the store believed both.

Schema v10. Atoms gain session_id, importance, recall_count, last_recalled_at
and archived_at; two tables arrive beside them — consolidations (one watermark
per holder+scenario lane) and conflicts (a pair, the term they disagree on,
both values, and whether anyone has settled it). v_atoms_current now also
hides archived rows, and apply() drops the view before rebuilding it because
CREATE VIEW IF NOT EXISTS would keep the old body and ignore the archive.

Working memory is a read, not a store: `working <holder> <session>` returns
what that session recorded, in order, with no search involved. Consolidation
rolls a lane's new memories into one standing summary and moves the watermark
to the newest memory actually rolled up — not to the clock of the pass, which
made a second pass count the same memories twice. The archive rule is stated
in the command that runs it (recorded before N days, never returned by a
search, importance at or under a floor) rather than hidden in a decay formula
nobody measured; --dry-run names the victims first.

A conflict is recorded when a new memory repeats a label with a different
number in the same holder's same scenario. Retrieval drops the older side
while the conflict is open and both sides answer again once it is settled:
settling is not superseding, and a word-only disagreement is left alone
because a reworded sentence would fire on every paraphrase. Numbers are keyed
to both the memory's subject and the nearest preceding label, measured: the
two thermostat sentences share only their subject, so a nearest-word-only rule
found nothing.

Search now counts the memories it handed back, which is the only use estimate
the store has, and the archive rule reads it. The dense index applies the same
currency rules as the view, so an archived memory cannot stay findable through
one leg while the other hides it.

Two reads over HTTP (working, conflicts) and two tools over MCP
(working_set, memory_conflicts); curation stays with the operator because an
agent should not decide what its own past is worth. remember and supersede
carry a session id; importance does not cross MCP, because recall_count is
observed and importance is stated.

Gates: 366 passed; ruff clean; mypy 24 errors in 9 files, the recorded 9cb46c1
baseline, net 0; pyright 0 on the touched files; go build/vet/test and gofmt
clean; mcp_write_smoke PASS.
2026-10-09 07:13:11 -04:00
ZacharyZhang-NY b696fc78e9 Say what the stop-word list costs and refuse a busy smoke port
The function-word list is fixed on purpose, and a fixed list cannot tell
"the" from "May": both are on it, so a claim built only from words that
double as function words offers nothing to compare and is never citable.
Measured — The meeting is in May, I will do it tomorrow and It can do
that all yield no claim terms, while Will Smith signed the lease still
yields signed / smith / lease. That is fail-closed, but the docstring
claimed the opposite ("only counted if it carries content") and the
checklist never said it. Both now state it, and a test pins it.

The checklist also carried three wrong pins for that list (line, size,
one-line comment) and named "ai" as already excluded when it is not: a
mixed claim still demands ['档案','温器','八度','ai']. The mechanism line
said "the three longest words", which is true of Latin and false of
CJK, where the demanded pairs are spread across the claim.

The smoke script trusted any answer on its port. With a server left over
from another run it measured that one and reported a false failure; it
now refuses a busy port and checks that its own process is still alive
before calling the server ready.

Gates: 325 passed, 60 in the four acceptance files, ruff clean, mypy
cold cache 24 errors in 9 files (the recorded baseline, net zero),
pyright 0, filesize clean, product audit clean, zero multi-line comment
blocks across 141 tracked files, smoke passes and exits 1 on a collision.
2026-10-09 05:10:31 -04:00
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 9f91ff7ed7 Let the console search, browse, forget and mount
The store had a write path, a mount path and a search path, and the console
still showed a dashboard of counters. Nothing in the UI could ask what someone
knows, page through atoms, look at a wordless picture, forget one memory, or
mount a cartridge — so the mechanisms built in P0 were invisible.

Backend:
- senses.py: one function decides what this machine can perceive. /api/status
  and doctor each computed it before, which is two truths. Picture atoms are now
  counted by image_ref rather than by modality, so a screenshot crop from a PDF
  counts and a picture whose bytes were deleted stops claiming it can be encoded.
- browse.py: GET /api/atoms pages newest-first through v_atoms_current with
  modality/holder/source filters; GET /api/atoms/{id}/image serves the stored
  bytes so a picture with no OCR text can be looked at.
- Deleted 8 HTTP routes nobody calls (4 benchmark, research modules, dense index,
  visual-contradiction-proof, memory-map). Their functions stay: the CLI and the
  proof checks use them. The surface is 30 routes, pinned as a set by a test so a
  new unused route fails the suite.

Frontend: App.tsx (517 lines) became a 131-line shell plus seven panels that own
their own fetches, and lib/format.ts. types.ts now carries the memory dimensions
the backend has been sending since schema v2.

Two invented fields were rendering lies: AdapterPanel showed gpu_available and
probe_status, which AdapterLoadReport does not have — every machine looked GPU-less
including this one. The panel now shows only what the server says.

Proof: scripts/ui_smoke.sh starts the API on a fresh temp home, serves the built
UI from the same origin, and drives five interactions in a real browser — search
as somebody in a scenario, page and view a wordless picture, forget, mount, read
adapter states with reasons. It prints the mount refusal verbatim rather than
pretending the mount succeeded.

Gates: 263 pytest passed; ruff and mypy clean on the 8 touched backend files
(repo-wide mypy unchanged at 24 errors / 6 files); pyright 0 errors there;
go build/vet/test/gofmt clean; npm build + eslint clean; ui_smoke and
mcp_write_smoke both PASS.
2026-10-08 05:13:38 -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 dfc6cd4c28 Give the system a directory: ask who knows, then ask them
A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.

- schema v5 adds `holders` (person/agent/service, declared topics, contact).
  A holder with memories exists whether or not anybody registered them; the
  registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
  filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
  directory with a visited set and a hop budget. Three terminal states:
  answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
  declared topics. Every hit carries the raw numbers and the basis lines that
  produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
  holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).

Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
  phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
  is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
  visibility clause and hid a memory the asker was allowed to read. The SQL
  clause is the only authority now.

A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.

Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
2026-10-07 17:20:45 -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 674c88e84e Rank memory search in SQL instead of rebuilding BM25 in Python
The product path pulled every atom into Python and rebuilt a BM25Okapi
index per query: p50 20.16s over 165,973 atoms. It now runs one SQLite
statement with LIMIT/OFFSET.

- text_index.py: one normalizer for both sides of the index. CJK runs are
  split per character (unicode61 cannot segment them), camelCase and
  separators are split so "search atoms" finds SearchAtoms, latin tokens
  get a prefix match. Rows written before this existed hold raw text in
  atom_fts, so schema v3 rebuilds the index on upgrade.
- retrieval.py: filters (holder / scenario / source / modality / since /
  until / min_trust), a base64 cursor, and reciprocal rank fusion in place
  of the 0.55/0.35/0.10 blend whose graph term was always zero.
- An empty result is {status: "no_match"}, not a bare array.
- Go: search_atoms and compile_context rank through POST /api/search. The
  second Go tokenizer is deleted; two implementations could drift, which
  is what the MCP/HTTP parity requirement forbids.
- db.py passed 500 lines, so the row codec moved to row_codec.py.
- Tooling: installed the repo's own pyright. pyrightconfig.json had no
  exclude and swept the downloaded corpus (1171 errors); with excludes the
  count is 62, all pre-existing (evals 48, profiles 4, training 4,
  ingestion 3, runtime 2, cartridge_assets 1). backend/pyproject.toml
  carried a second [tool.pyright] that shadowed the root config; deleted.
- bench/scale_check.py measures the product search at corpus size. The
  100k-atom p50 number is not written until it is measured on an idle
  machine.

Verification: 112 pytest passed; go build/vet/test clean, gofmt clean;
ruff clean on every touched file; mypy clean on the 14 touched Python
files; repo pyright 62 errors, unchanged per-file ledger.
2026-10-07 16:02:40 -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