Commit Graph
70 Commits
Author SHA1 Message Date
ZacharyZhang-NY 246dae949c Record that the write path passed independent review on round eight
Eight rounds, 29 findings, the last two rounds measuring only the
record. The reviewer also walked a third smoke path I had not tested:
a non-Ely-Eye HTTP server on the smoke port slips past the port guard
because it answers /api/status with 404, and is caught by the process
liveness check instead.

Two precision notes from that round are folded into the limitation
line: the demonstrative and interrogative words are on the stop-word
list too ("This is important" and "What caused the outage" are never
citable), and the sentence that dies through "over" also dies through
"she".
2026-10-09 06:05:45 -04:00
ZacharyZhang-NY d56996b454 Name the words the stop-word list actually drops
The limitation line listed "her" as a word the fixed list removes. It
is not on the list, and that matters: "her" is still counted as a
demanded word, so "I can meet her tomorrow" survives with
tomorrow / meet / her, while "She looked over the report" dies through
"over". The line now names the measured membership instead.

The same row described "ai" still occupying a slot as behaviour from
before the fix. Nothing ever changed "ai" — it was never a function
word — so the observation is current, and the row said both things at
once.

Also pinned the D15 example to its full string: the ellipsis made it
non-reproducible, and lengthening the same claim moves the middle
position onto filler and flips the verdict, which is the boundary the
next row already discloses.

Gates after the edit: 325 passed, ruff clean. Code untouched since
b696fc7.
2026-10-09 05:41:35 -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 2ea48d8530 Stop a borrowed word from making a short claim comparable
A claim too short to compare in its own script was rescued by a Latin
filler: 恒温器 the offered two Chinese pairs, the rule asked for three
terms, and "the" completed the set — so the claim was corroborated by
anything that happened to carry the same filler. English was isomorphic:
Pineapple answer stayed uncited, Pineapple answer the did not.

The shortness test is now per script, and function words are out of the
demanded set. A mixed claim still asks for its three Chinese positions
plus its longest real Latin word, so dropping "the" from the demand no
longer blocks a legitimate witness: 档案室的恒温器保持十八度 the is now
corroborated by 恒温器一直放在档案室,读数十八度, which the last review
round measured as False.

A witness that translates the demanded Latin word is still read as
disagreement, and a witness that inflects a demanded verb
(holds / hold) is too: the rule is term-level, and the checklist now
says so with both measurements instead of the earlier blanket claim.

Gates: 324 passed, 59 in the four acceptance files, ruff clean, mypy
cold cache 24 errors in 9 files (the recorded baseline, net zero),
pyright 0 on both touched files, filesize clean, product audit clean,
MCP write smoke passes.
2026-10-09 03:52:19 -04:00
ZacharyZhang-NY 8d84e03077 Corroborate a mixed-script claim in both scripts
A claim written in Chinese and English was checked only in English: the
longest Latin words took every slot the rule demands, so one planted
English word moved the closing Chinese pair out of the comparison and a
witness that contradicted the number became a second source. The attack
costs nothing — append "the" to a Chinese sentence — and it fails open,
which is the wrong direction for a rule whose whole job is to keep an
unvouched claim out of the prompt.

Each script is now checked in its own terms: a claim with CJK pairs is
demanded at its opening, middle and tail, plus its single most
distinctive Latin word. A Latin-only claim keeps its three longest
words. The demanded set is what claim_terms returns, all of it.

Measured: the planted-word witnesses are no longer believed, a verbatim
repeat still is, and a mixed claim that agrees in only one script is not
a witness. The term-level boundaries are listed in CHECKLIST for both
scripts, including the two the reviewer found that this rule does not
catch: a demanded pair inside a longer word, and a long claim whose
middle position lands on filler.

Also corrects the record: the boundaries were not the same in both
scripts as claimed, a historical line said "now" instead of naming a
commit, and a limitation line I deleted in the last round is restored.

Gates: 322 passed, ruff clean, mypy 24 errors / 9 files (baseline, net
zero), pyright 0 on the two touched files, filesize clean, product audit
clean.
2026-10-09 03:12:56 -04:00
ZacharyZhang-NY 334071199e Make corroboration check what a claim asserts, not how it opens
An independent reviewer planted two witnesses that contradict their claims
and both were believed: 「恒温器很贵」 was corroborated by 「恒温器很便宜」,
「这房子值得买」 by 「这房子不值得买」. The Chinese pairs were spread inside
each run on its own, and equal-length pairs kept insertion order, so the
three demanded pairs of a multi-clause claim all came from its first
clause — and for a four-pair claim the evenly spaced index picked 0, 1, 2,
clustering them at the start anyway. An unrelated memory that merely
opened the same way pushed a quarantined sentence into the packed context.

Pairs are now pooled across the whole claim and the demanded positions are
its start, its middle and its end. The last pair is the assertion, so a
witness has to agree with the claim rather than merely introduce it. The
term-level boundary is pinned in both languages: a restatement that keeps
the compared positions counts, one that replaces them does not, a denial
that differs by one word still counts, and a changed number does not.

Also corrected the record where it had drifted: the gate cell was labelled
with a commit that never produced those numbers, the acceptance cell pinned
a total its own command could not reach, and the reader-path count stated a
conclusion without its measurement rule.

Gates: 320 passed; ruff clean; mypy cold cache 24 errors in 9 files, net
zero against the recorded baseline; pyright 0 errors on the touched files;
filesize, product audit, go suite and the two-principal MCP smoke all pass.
2026-10-09 02:28:50 -04:00
ZacharyZhang-NY ff2471d362 Give Chinese memories a way to be corroborated
An independent reviewer repeated the reviewer's own counterexample in
Chinese and found the corroboration channel was dead for it: fts_terms
counts one CJK run as one term, so a Chinese claim could never reach the
three words the rule demands, however many independent sources repeated
it. A run is now broken into its adjacent two-character pairs and three
are taken from across the sentence, so a second source has to agree in
three separate places rather than copy one stretch of characters. The
pairs belonging to an injection phrase are stripped too, so an
instruction cannot vouch for its own characters.

Corroboration stays term-level in both languages: a paraphrase that
keeps the meaning and changes the wording is not a second witness, and a
claim with fewer usable words than the rule demands is never believed.

The record was wrong in four places the same review measured: the token
rename breakdown summed to 11 while claiming 13 across 6 files (it is 13
across 7), the acceptance cell counted tests that no longer exist, the
gate cell quoted the previous round's totals, and a cited line had
drifted onto a blank. The section also stops calling the injection work
"sanitization": 19 literal phrases plus quarantine plus one prompt
sentence is detection, and a reworded directive walks straight past it.
2026-10-09 01:26:17 -04:00
ZacharyZhang-NY 203b6d04af Make corroboration depend on the claim, not on the size of the library
An independent reviewer broke the quarantine rule twice by running it. In a
two-atom library, a second source repeating the claim word for word could not
vouch for it; 40 unrelated memories could. The old rule handed the choice of
words to rare_terms, which drops words common to the corpus, so the corpus size
and the attack's own words decided whether a stranger was believed.

Corroboration now demands the claim's three longest words, with the instruction
phrases stripped out, ANDed by the new text_index.fts_all_expression. Neither
the library's size nor the attack can flip it, and both cases are tests.

Also from that review:
- A question is a read. The DF cache no longer remembers a word the corpus has
  never used, so an unauthenticated search cannot grow the store one row per
  novel word. "A question is not a write" was false for /api/search and /api/ask.
- The prompt now states that packed evidence is quoted material, not an
  instruction: once a quarantined sentence is corroborated it does reach the model.
- ForgetReport's semantic_cache_cleared reached the frontend as well as Go.
- runtime.py: process_vision_info returns a third element only when
  return_video_kwargs is set, and this call does not set it, so the unpack takes
  the first two. Two pre-existing pyright errors go, behaviour is unchanged.

CHECKLIST corrected against measurement: 13 renamed lines rather than 11, and
10 English / 9 Chinese injection signals rather than 11 / 8.

Gates: pytest 312 passed, ruff clean, mypy 24 errors in 9 files (net zero),
pyright 0 errors on the five touched files, npm build and eslint clean,
scripts/mcp_write_smoke.sh PASS.
2026-10-09 00:37:12 -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 8b974e6626 Record that the scale work passed independent review on round five 2026-10-08 21:07:33 -04:00
ZacharyZhang-NY 6869d0956f Compare each modality against the page it was measured on 2026-10-08 20:53:48 -04:00
ZacharyZhang-NY 4ed2dc04c6 Back every claim in the scale record with the artifact that measured it
Seven sentences in the log were either one run deeper than the committed
reports, a delta computed across two different configurations, or a claim
about a path that was never measured. Each now quotes the number the report
carries, names the sample behind it, or says plainly that the figure was
never captured and is therefore not evidence.

The truncation rows were measured on query-time slices, so the record says
so instead of describing a storage layout nobody tested. The gate line names
the file set it covers: 29 Python files for pyright, and the three frontend
and one Go files that moved in the same range are covered by the build and
lint gates that actually apply to them.
2026-10-08 20:30:20 -04:00
ZacharyZhang-NY fe538ac6a3 Say which commit each gate number belongs to
A gate record is a measurement of one tree. Three lines in the log read as
if they described today's checkout, and one of them quoted a count taken
before a later commit fixed the thing it counted.

Each line now names the commit it was measured against, the command that
produces the number, and what is in the file set — the pyright figure covers
46 Python files because that commit touched no frontend file.
2026-10-08 20:00:52 -04:00
ZacharyZhang-NY e80883cfc9 Re-stamp the scale reports against the harness that now carries the fix
Both reports were produced by a harness that had just changed, so their
stamps named a commit without that code in it. Re-running them on an idle
machine puts the stamp and the source back in sync.

Recall did not move: all seven leg configurations reproduce to the third
decimal, which is the part of the number anyone should act on. Latency moved
within its usual load band, and the encoder probe came in slower this time,
so the picture index's build cost is now quoted as a band rather than a
single remembered figure.
2026-10-08 19:30:46 -04:00
ZacharyZhang-NY 71733d86d6 Stop the checklist from quoting numbers no artifact carries
A review of the p2-1 record found seven places where the prose had drifted
away from the reports it cites. None were product defects; all were record
keeping, and record keeping is what a memory system is judged on.

The quantization row mis-transcribed nine latencies, and the two worst made
dimension truncation look twice as fast as the full scan when the report
measures six percent. The extrapolation table scaled latency by atoms and
bytes by vectors under one heading that named neither switch. The picture
index stats existed only in prose, so the harness now reads the index shape
off the artifact and times the encoder on a sample, which reproduces the
hand-timed build within seven percent. The dense arms named a store that
holds no vectors, so the report names the store the vectors came from and
refuses to run where a dense leg would rank an empty index.

The frozen baseline was migrated in place by the product CLI while the
picture index was built, so its pinned mtime, digest and schema version are
re-pinned and the step that moved it is named. The sqlite_vec import added
two mypy errors; the fix is the missing stub declaration, not an inline
ignore, and the count is back to the recorded baseline of twenty-four.
2026-10-08 18:51:41 -04:00
ZacharyZhang-NY 6cc5640856 Keep the ablation arms inside the line budget the repo enforces
copy_store() moved into bench/scratch.py: systems.py had reached 512 lines
and bench/filesize_check fails anything over 500. The move is behaviour
identical, so the scale reports keep the commit stamp they were generated
against — the code they describe is complete at that commit.
2026-10-08 17:04:16 -04:00
ZacharyZhang-NY c4a8c36416 Put the scale evidence in the repo and say plainly what still misses
Every number in CHECKLIST p2-1 and p0-3.7 came out of a scratch script: the
kind of artifact a reader cannot re-run, and the kind that quietly drifts
away from the code it describes. These reports are generated by committed
harnesses and each one stamps the commit that carries the code it measures.

DENSE.md  165,973 atoms / 153,857 vectors: build 1421.0s, search p50 75.6ms
          p95 93.0ms, 0.998 agreement with a brute-force scan, one write into
          a full index p50 518ms, 1,729 bytes per stored vector.
QUANT.md  twelve encodings measured on the same vectors: int8 keeps 0.977 of
          the ranking for a quarter of the bytes and no speed, bit is 14x
          faster and keeps 0.728, so bit is a coarse filter and nothing more.
TERMS.md  the stop-word bound swept, not guessed: 0.05 is the measured peak
          (recall@10 0.803 at p50 69.5ms) and also the fastest of the rows
          that keep recall.
SCALE.md  the product's own search at 165,973 atoms: p50 71.7ms against the
          frozen baseline's 20.16s.
LEGS.md   what each retrieval leg is worth, per modality, with the page size
          the product actually asks for in the table.

The acceptance bar the operator set is recall@10 >= 0.90 at n>=300. It is
not met. The best measured row is 0.858 (dense leg on, page widened to 30,
p50 414ms); the shipped configuration gives 0.848 at p50 72.5ms; the
in-repo counterpart at holder scope gives 0.866. The shortfall is
concentrated in picture golds (video 0.583-0.708, ui_screenshot 0.500-0.583,
image 0.750) while the word-shaped ones already pass (code 0.953, pdf 0.919).
Latency passes by three hundred fold; recall does not, and the gap is four
points of cross-modal retrieval, not index scale.
2026-10-08 17:01:20 -04:00
ZacharyZhang-NY dff5977ab6 Measure what each retrieval leg is worth before choosing a default
The product fuses a sparse page, a dense vector leg, and a picture leg that
only speaks when the words come back thin. Two of the three are off by
default, and until now no committed artifact said why.

bench/leg_check.py asks the frozen suite's 300 answerable queries as their
own holder, once per configuration, and prints recall, first-hit quality,
latency and per-modality recall in one table. The picture index and the
CLIP snapshot are 48 minutes of encoding, so they are carried in rather
than rebuilt per row; the harness refuses to run a leg whose snapshot is
missing instead of quietly scoring nothing.

The numbers that came back:
- words only, 10 atoms: recall@10 0.832, MRR 0.683, p50 64ms
- words only, the configured 24: 0.848, 0.688, 69ms
- words only, page 30: 0.855, 0.690, 67ms
- dense leg on: 0.845 / 0.858, MRR down, p50 469ms / 411ms
- picture leg available: 0.815 / 0.825, MRR 0.553 / 0.551, p50 148ms / 164ms

So both defaults stay off, and the acceptance bar (recall@10 >= 0.90) is
not met by the product path yet: 0.858 is the best measured row, the
in-repo counterpart at holder scope is 0.866. The loss concentrates in
picture golds, and the current CLIP leg pays more in dilution than it
returns there.

CHECKLIST p2-1 and p0-3.7 now quote the committed reports (DENSE.md,
QUANT.md, SCALE.md, TERMS.md) instead of scratch scripts.
2026-10-08 16:15:18 -04:00
ZacharyZhang-NY 91fbff34ee Let every harness work on a copy of the frozen store
copy_store lived in dense_scale, so the two harnesses that also needed it reached back into a
measurement module for a plumbing helper. It now sits in systems beside the product path it
protects, and bench.run passes a scratch directory to the live check: the product path migrates
whatever database it opens, and the frozen baseline is cited evidence.
2026-10-08 15:11:27 -04:00
ZacharyZhang-NY b8f83f422a Put the stop-word bound's evidence in the repo, not in a scratch script
COMMON_TERM_FRACTION = 0.05 is a magic number unless something prints the curve it
came from. bench/term_sweep.py asks the frozen suite as its holders would, once per
candidate bound, and writes recall and latency into the same row of
bench/reports/TERMS.md.

rare_terms now resolves its bound when the call runs instead of when the function was
defined, which is the only way a harness can move it. The default value is unchanged,
so the reports stamped at the parent commit still describe this code.
2026-10-08 14:36:59 -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 be08e7bf9c Record that the file-size work passed independent review on round three
Round 1 found a real defect: moving code between files dropped three
multi-line docstrings, and what dropped was contract — the rule that
every search filter is one `? IS NULL OR` predicate in a literal
statement, the as_holder sharing rule, the five arms of the ablation.
Round 2 found the line numbers had been read off a tree pi-lens reflowed.
2026-10-08 10:27:22 -04:00
ZacharyZhang-NY 9682657da7 Cite the migration ladder from the commit, not the drifting tree
The previous fix quoted migrations.py:248,250, which are a PRAGMA and a
blank line in the committed file. Those two numbers came from a working
tree pi-lens had already reflowed, so the ladder looked nine lines lower
than it is.

Cited from the commit: 181 and 186 define the v2 steps, 239 and 241 call
them, 243 rebuilds FTS for v3. The other six line citations on that
CHECKLIST line were re-checked the same way and hold.
2026-10-08 10:19:03 -04:00
ZacharyZhang-NY 28959c51c6 Restore the contracts the split dropped and correct the record
A split moves code; it does not get to rewrite what the code promises. The
first pass collapsed three multi-line docstrings into one-liners and the
line counts in the checklist were measured before that happened.

- store_search.py: search_atoms keeps its filter-shape rule and the
  as_holder sharing rule, FtsPage keeps why the cursor is an offset
- bench/run.py: the module docstring says what the five arms are again
- CHECKLIST: 116/341/83 lines, 11 call sites over 7 methods, and the
  migration ladder now cites 181/186/248/250 instead of the parent's lines

Repo-wide multi-line docstrings are back to 114, the parent's count.
2026-10-08 10:02:24 -04:00
ZacharyZhang-NY 9cb46c18d3 Keep every file inside the rules the repo already wrote down
The 500-line rule and the one-line-comment rule were enforced only from
memory, so they drifted. bench/filesize_check.py now checks the budget on
every tracked source file, and it passes.

Split, with behaviour proven unchanged by comparing each moved function body
against HEAD byte for byte (53 training bodies, 21 benchmark bodies, 0
differences):

  db.py        503 -> 396   store_search.py     105
  bench/run.py 609 -> 351   query_set.py         80, report.py 211
  benchmarks.py 619 -> 424  benchmark_scoring.py 262
  training.py 1630 ->  94   training_{common,encode,metrics,datasets,lora,retrieval}

TrainingService stays as a thin facade for the CLI; the facade's parameter
lists are pinned against the module functions by inspect, which is what
catches a leaked self.

Deleted the eight top-level functions no file called, after fixing the scan
that had reported 201 false positives by skipping the defining file. The
v1/v2/v3 sweep found nothing to delete and says so: every remaining version
tag is an on-disk format marker, a migration step, a fixture name, or a
third-party model name.

Collapsed 39 multi-line comment blocks (86 lines) to one line each, keeping
the measured facts and error strings that justify the code.

269 tests pass; ruff clean; mypy 24 errors in 9 files against a 24-in-6
baseline, net zero; pyright 4, the same four HEAD measures.
2026-10-08 08:20:21 -04:00
ZacharyZhang-NY 56b94cd58e Delete the eight functions nobody called
Measured, not guessed: every top-level def in every tracked .py file was
counted against every other tracked file, including markdown, scripts, Go
and TypeScript. Eight names had zero references anywhere:
cartridges_holding_atom and clear_mounts (memory.py reads each cartridge's
own sources.json, and set_mounts already clears by replacing the set),
gradient_checkpointing_enabled, group_hits_by_source, ingested_paths,
is_finite_number, parse_runtime_json (the runtime answers in prose now, and
the JSON path was never wired back), write_sources_json.

Typer commands and pytest cases were excluded from the count because a
framework calls them by name. The same scan now returns zero.

263 tests pass; repo-wide mypy is still 24 errors in 6 files, unchanged.
2026-10-08 07:38:36 -04:00
ZacharyZhang-NY 330e801e92 Name the line budget as a gate, even while it fails
p1-6's acceptance names bench/filesize_check, and the file did not exist:
the 500-line rule was enforced by memory. It now runs, and it fails on
training.py 1639, benchmarks.py 619, bench/run.py 609 and db.py 503. That
is the honest state of the tree, not a passing gate invented after the work.

The console work passed independent review on round three: the reviewer
re-ran the smoke, then broke the pager three ways and watched each break go
red - a pager that does nothing, a pager that appends, and a Previous button
that stops restoring.
2026-10-08 07:35:09 -04:00
ZacharyZhang-NY b0895eb36f Prove the pager pages instead of proving the row count moves
The E2E waited for the number of `.atom-row` elements to change, but the
search list and the browse list share that class, so the count it watched
was 12 browse rows plus one search hit. A pager that did nothing still
passed. Rows now carry `data-atom` and the two lists carry `data-testid`,
and the test asserts the ordered id list: page one holds exactly PAGE_SIZE
rows, page two shares none of them, page two holds total - PAGE_SIZE rows,
and Previous restores the first list.

Both mutations the reviewer suggested now fail: dropping setCursor and
loadBrowse from nextPage gives "Next page did not swap the page: the pager
is not paging", and appending instead of replacing gives "the next page
repeated 12 of 12 atoms from the first page".

The record also said 130 lines for App.tsx when it is 131, and quoted a
mount refusal the harness had cut off mid-sentence; the refusal's own
remediation ("set ELY_EYE_MODEL_DIR to the model snapshot") is the useful
half, so the slice is 200 now and the quote is the product's full sentence.
2026-10-08 07:05:29 -04:00
ZacharyZhang-NY 6df713d135 Keep the self-report shapes in a module the line budget can hold
9f91ff7 pushed schemas.py from 498 to 534 lines and broke the 500-line rule
the repo enforces on every file. The status family (CacheLayerStatus,
MemoryStatus, RuntimeStatus, SenseStatus, SystemStatus) moves to
status_schemas.py, where it is self-contained: it needs only
TrainingAdapterKind from schemas.py, and schemas.py no longer looks back.

The same commit added three multi-line docstrings (browse.picture,
senses.sense_facts, SenseStatus). Each is now one line; the absent-vs-missing
distinction they carried survives in SenseStatus's own line, and the producer
senses.py still states why the two failures need different sentences.

Measured, not asserted: 263 tests pass; ruff clean on the eight touched
files; repo-wide mypy still 24 errors in 6 files (runtime.py's two are in
that baseline and reproduce on the HEAD copy of the file); pyright reports
the same two pre-existing runtime.py tuple findings on both trees;
multi-line docstrings repo-wide are back to 114, the parent's count.
2026-10-08 06:44:36 -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 a5008e1476 Close p1-4 after three review rounds
The reviewer ran things rather than reading my claims: gh run view, pytest,
ruff, cold-cache mypy diffed against the pre-p1-4 tree, pyright, the full go
suite, bash -n on all 15 .sh, pip wheel, sha256sum of LICENSE against gnu.org,
the 22.4GB container answering three URLs, and three mutation tests on the
harness stubs. Round 1 failed 9 checklist numbers, round 2 failed one two-line
docstring, round 3 passed and measured that p1-4 now adds zero multi-line
docstrings repo-wide (114 -> 114).
2026-10-08 04:21:04 -04:00
ZacharyZhang-NY 62e2a14a3d Keep the new stub's docstring to one line
The reviewer's round 2 verified all nine round-1 corrections by re-measuring
them, then failed the commit on the one thing it added that the repo's own bar
forbids: a two-line docstring on _TinyDense. p1-6.4 still lists "comments to one
line" as open work, so a new commit should not widen it. The 470MB rationale
already lives in CHECKLIST.md.

Also cites the CI run for the commit under review (37744547992 @ 20c0416, also
success) instead of stopping at the previous one.
2026-10-08 04:12:52 -04:00
ZacharyZhang-NY 20c041698d Correct the p1-4 record against what the reviewer measured
Nine claims in CHECKLIST.md did not reproduce. All nine were prose, not
product, and all nine are now the measured value:

- test files: 19 (18 test_*.py + conftest.py), not 24
- suite size: 253 passed (239 at 922d012), not 252
- build context: 81G on this machine (bench/runs 60.2GB, bench/corpus 20.7GB,
  .venv 2.5GB), not 19GB
- profiles.py probes: linux_package_versions catches the 3-tuple because
  json.loads needs ValueError; runtime_launcher_probe and gpu_available catch
  the 2-tuple. The old sentence claimed one tuple for all three.
- training.py: the CUDA raise is at :1271, not :1272
- cli.py is 490 lines at this commit, not 488
- the bf16 falsifier said "a 4090 that does not support bf16". A 4090 is Ada
  (compute 8.9) and does support BF16; the test fabricates the flag. Naming the
  card was exactly the error this commit removes, so the sentence now names the
  flag.
- the backup digest is a fingerprint of one store snapshot: hashing is
  deterministic per state, but the store was rewritten after the commit, so the
  current digest differs (498b0cd9...). The record now pins mtime, size and row
  counts next to the digest.
- wall times moved between runs (backup 4.0s vs 2.24s, restore 2.8s vs 5.65s),
  so they are reported as a range with the reason instead of a single number.
2026-10-08 03:37:06 -04:00
ZacharyZhang-NY 4b367cb59b Let the harness tests run on a machine that has no models
CI failed four tests with "SystemExit: bench/models/paraphrase-multilingual-
MiniLM-L12-v2: model not downloaded". The claim those tests make is that the
joint ablation pass ranks exactly like the per-arm pass and that three arms
needing BM25 score the corpus once. That is a claim about the harness, not
about embedding quality, so it should not require a 470MB download to check.

The tests now inject a deterministic 384-dimensional stand-in dense model and
a stand-in cross-encoder at the seam both paths share. The real models still
carry every measurement in bench/reports; nothing measured changed.

Proven by the condition that broke: with both model directories moved out of
the tree, the whole suite passes (253 passed).
2026-10-08 03:04:45 -04:00
ZacharyZhang-NY 07825913fe Make the store movable and the CI gate actually runnable
CI was a workflow that could not run its own tests: pytest is not a
dependency of anything, so a fresh clone fails with "No module named
pytest". Test tooling now lives in the backend's dev extra, so CI and a
new contributor install the same tools, and ruff is pinned there instead
of drifting as a bare version in a workflow step.

backup, restore and doctor move the store. Backup uses SQLite's own
online API so a running server is safe, and the manifest records the
schema version, row counts, sha256 and size read back from the copy.
Restore verifies the digest, integrity_check, required tables and schema
age before it touches the live file, and keeps what it replaces as
.before-restore.

Doctor separates broken from degraded. On the 906 MB baseline store
(165,973 atoms) it reports no problems and one true warning: 21,362
picture atoms with no CLIP snapshot configured, so those atoms are
findable only by the text already on them. The first version called that
store healthy because it only complained about paths that were set and
missing, never about a sense the store needs and never had.

AGPL-3.0-only, because PyMuPDF is dual-licensed AGPL-or-commercial and
the repository is public. A permissive license needs the Artifex
commercial license bought first; the README says so rather than hiding it.

Gates: 253 tests pass; ruff clean repo-wide (17 errors at HEAD); mypy
unchanged from HEAD on touched files; pyright 0 errors on the new files;
backup 4.0s and restore 2.8s on real data with counts verified equal.
2026-10-08 02:32:52 -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 36f40c4c59 Keep the working tree out of the image build context
Without a .dockerignore a build from the developer machine sends the 19 GB
corpus, the virtualenv and node_modules to the daemon; the Dockerfile reads
none of them.
2026-10-08 01:40:23 -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 908157f82e Record that adapter resolution passed independent review on round four
The reviewer ran its own probes and mutation tests rather than reading the
prose, and confirmed the repo rules. One of its observations, that mount
state is per-connection, I disproved by measurement: mounts live in a table
and a second Database object on the same home reads them back, so the test
asserts an empty mount list only because that test mounts nothing.
2026-10-08 01:34:02 -04:00
ZacharyZhang-NY c48b48b80f Say plainly when several live models hold different adapters
Round three of the adapter review found two leftovers from the previous fix.

With three or more live runtimes the reason read "another live model still
holds adapter-a and adapter-b", a singular subject attributing several
adapters to one model, when each runtime attaches one adapter per kind. The
subject now agrees in number with what is held.

LoadRecord still stored its own copy of the manifest sha256, which is the
same second source of truth this work removed for kind, adapter_id and path:
the only constructor copied it, and nothing stopped a caller from passing a
hash that disagreed with the source handed in. It is a property now, so
runtime.py keeps reading record.sha256 and there is one value.

Also recorded, measured by the reviewer and deliberately not treated as
defects: a row for a kind with nothing on disk now reports base_model as null
rather than the runtime's model id, since that row describes no adapter at
all; the same adapter id can appear in both a loaded row and a shadowed row
when it is attached while another copy wins the mount, and both rows are
true; and when the attached adapter is deleted from disk the row says missing
with null identity instead of claiming a gone model.

Three tests carry this: the three-runtime case asserts the plural wording, and
both fixes were mutation-checked (dropping the plural branch fails two tests,
restoring the stored hash fails the generation test).

pytest -q: 228 passed. ruff clean. mypy cold cache on the three touched source
files: 2 errors, identical to the stashed HEAD tree, net zero. pyright: 2
errors, both the known qwen_vl_utils tuple artifact.
2026-10-08 00:51:01 -04:00
ZacharyZhang-NY 7ec25bf629 Make a loaded adapter row describe the adapter it actually loaded
The report row contradicted itself. The reason came from the attachment
record while path, adapter_id, base_model, origin and cartridge_id came from
whatever resolution preferred right now. Reproduced without any stubbing: a
live model holds adapter X, training writes a newer adapter Y, and the
endpoint then answers state=loaded adapter_id=adapter-Y while explaining
"attached to the live model from .../x-attached". A JSON consumer reads the
identity columns as the loaded adapter and gets the wrong one.

The same root cause had a second symptom. Attachments were keyed by kind with
last-writer-wins, so with two live runtimes one unload made the row claim
"that model is gone" while the other runtime's model was still alive holding
its adapter.

LoadRecord now carries the AdapterSource it came from, so a row built from a
record has exactly one identity, and the registry is a list of attachments
each holding its own weak reference: loaded means some live model still holds
an adapter, and when more than one does the reason names the other.

clear_loads had no production caller once unload stopped erasing the process
record, so it is gone rather than kept as a test-only entry point.

Two tests pin both symptoms, and both were mutation-checked: restoring the
identity to the resolved source fails the first, and letting
live_attachments ignore liveness fails the second and the unload test.

pytest -q: 227 passed. ruff clean. mypy cold cache on the three touched
source files: 2 errors, byte-identical to the same command on a stashed HEAD
tree, net zero. pyright: 2 errors, both the known qwen_vl_utils tuple
artifact, unchanged from HEAD.
2026-10-08 00:23:45 -04:00
ZacharyZhang-NY 2d1cfdadc7 Stop the adapter report from claiming things that are not true
Independent review of the adapter resolution work found six defects, all
real and all fixed here.

A kind whose only candidates were rejected had no winning path, so its row
carried path=None and the rejected candidate fell into the shadow loop: the
report said "another adapter of this kind wins for the current mount" while
nothing won, and the real deferred reason was buried. A shadow row now
requires a winner and names it.

"loaded" was backed by a process-global record, so a fresh runtime with no
model still claimed a live adapter. The record is now held by the instance
that attached it, and the report keeps a weak reference: while the model
lives the row says loaded, once it is gone the row says available and
explains that the model is gone.

The sort key had lost the manifest's cartridge tag, which changed which
adapter loads on any machine holding a tagged library copy, and neither the
commit message nor the checklist said so. The tag is back as the middle key.

Proof checks resolved through the library while generation resolved through
the mount, so one proof payload could name two different copies. Both now
resolve the way generation does.

loaded_at was written and never read, so it is gone. The checklist claim that
peft is lazily imported in one place was true only for generation and is now
scoped that way.

The four generation paths and status() had no test coverage, so "no function
was lost" rested on reading. One test now walks the transformers prompt path
end to end with stub weights and asserts the generation answer carries the
adapter it attached.

pytest -q: 225 passed. ruff clean. mypy on the three touched source files:
2 errors, the identical 2 on a stashed HEAD tree, net zero. pyright: 2
errors, both the known qwen_vl_utils tuple artifact, unchanged from HEAD.
2026-10-08 00:03:27 -04:00
ZacharyZhang-NY 2fbb4af96b Resolve adapters by what is mounted, and say why each kind is not loaded
runtime.py resolved adapters at 7 call sites and only ever looked in the
adapter library, so an adapter attached to a cartridge (cartridge.py:230
copies it into <cartridge>/adapters/{kind}/) was never loaded by anything.
Resolution now lives in adapters.py: it reads the mounted cartridge set,
prefers a mounted cartridge's copy over the library, and answers every one
of the six kinds with a state the facts support — loaded, available,
deferred (base_model mismatch or missing weights, both models named in the
reason), missing — plus a shadowed row for each adapter that lost the
choice. A mounted cartridge is the scenario selection; neither manifest
carries a scenario field, so none was invented.

Two bugs the new tests caught: the preference sort lost reverse=True, so a
library adapter beat a mounted cartridge's copy, and the load branch had
never been executed by a test. Stub weights now prove peft is asked to
attach, that the returned model is put in eval mode, and that the endpoint
reports loaded until unload clears it.

Faces: GET /api/runtime/adapters, `ely-eye adapters`, and a log line naming
the reason whenever a generation runs without an adapter.

Gates: 222 passed; ruff clean; mypy cold cache on the 7 touched files gives
the same 2 errors as HEAD's runtime.py, and pyright gives the same 2 as
HEAD — both are qwen_vl_utils typing artifacts (process_vision_info returns
2 or 3 values depending on return_video_kwargs; the product takes 2).
2026-10-07 23:09:28 -04:00
ZacharyZhang-NY 9f0f7993e5 Record that the speech work passed independent review on round three
The reviewer ran the harness and the gates itself rather than trusting the
prose: round one failed on 11 defects, round two on 4, round three passed
after re-running bench.asr_check in a fresh process, re-checking the stamp
chain and matching 18 checklist assertions.
2026-10-07 22:49:29 -04:00
ZacharyZhang-NY 2fa91abbc5 Freeze the speech report against the commit that fixes the prose
The report is regenerated after 10a39c5 so its stamp names the code it
describes. Speed is quoted as a range in the checklist because transcribe
seconds move with machine load (3.26s then 3.12s on this box) while WER,
denominators and word counts do not move at all.

Numbers: 258.18s of audio, WER 0.242 over 533 reference words, 80 audio atoms
and 563 word timestamps read back from the store, 0 out of order and 0 before
their own segment measured on the stored words.
2026-10-07 22:39:27 -04:00
ZacharyZhang-NY 10a39c5125 Stop the prose from drifting away from the report it cites
A second review found three numbers left behind when the report was
regenerated with pinned decoding:

- the transcriber docstring claimed 1.7 s for 88.9 s of speech; the report
  says 1.39 s, and 1.7 came from the unpinned run. The docstring now names no
  number, so it cannot go stale, and it stops calling container seconds speech
  with voice activity detection off.
- the checklist still quoted 3.55 s and 61.6x / 73.0x / 97.7x; the frozen
  report says 3.26 s and 63.9x / 82.4x / 112.2x.
- the checklist still said 31 speech segments for one mp4; with greedy decoding
  that video yields 38, which is where the store's 80 atoms come from
  (38 + 39 + 3).

The report also credited the store with timing invariants that were measured on
the model's answer. The harness now recomputes them from the words as stored
(out_of_order_in_store, before_their_segment_in_store) and says so.

Verification: 212 passed, ruff clean, mypy clean on both touched files with a
cold cache, pyright 0 errors.
2026-10-07 22:36:49 -04:00
ZacharyZhang-NY 836a4908e1 Freeze the ASR report against the commit that carries the code
Generated by .venv/bin/python -m bench.asr_check after 709104e, so the stamp
names the code under measurement instead of its parent. Two passes agreed:
WER 0.242 both times, transcripts byte-identical.
2026-10-07 22:24:56 -04:00
ZacharyZhang-NY 709104e809 Make the speech numbers reproducible instead of merely printed
A review of 5289cb4 found the ASR report was not honest arithmetic:

- "audio s" was the last segment's end, not the media length. The real
  container durations are 88.93 / 109.18 / 60.07 s, so the report understated
  the audio by 48 s and every speed cell was wrong.
- Decoding was unpinned, so a rerun was a new draw: WER moved 0.242 to 0.262
  and one video moved 0.278 to 0.583. Greedy single-temperature decoding with
  no carry-over now answers the same way twice, and the harness runs the pass
  twice to show it.
- The report claimed its WER was an upper bound. It is not a bound in either
  direction: a caption that drops words also hides words the model dropped.
  The denominators (201 / 296 / 36 reference words) are printed instead.
- "66 speech atoms" was bench-side arithmetic. The store now says 80 audio
  atoms carrying 563 word timestamps, read back after a real ingest.

Same silent-skip bug in a second place: a text file over max_text_file_bytes
returned an empty batch with no reason. It raises FileTooLarge with its size,
and a walk records that refusal like any other.

Also fixed from the same review: the Go search tool never published the audio
modality, cli_senses carried a second json_dump, the no-audio-track branch had
a test that fed it markdown, _seconds was an empty wrapper, and the docstring
quoted a speed no report measured.

Gate root cause: bench/tests imports the bench package by name with no root
conftest, so bare pytest could not collect it at all. pythonpath in pyproject
fixes both launch styles; 212 tests pass either way.

Verification: 212 passed (bare pytest and python -m pytest), ruff clean,
mypy clean on the six touched files with a cold cache, repo-wide mypy 24/6
unchanged from HEAD, pyright 0 errors on the touched files, go build/vet/test
and gofmt clean.
2026-10-07 22:23:33 -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