Skip to content

chore(openspec): archive the last two changes, plus pymmseqs and Colab fixes - #446

Merged
tsenoner merged 12 commits into
mainfrom
chore/reconcile-monorepo-openspec-change
Aug 13, 2026
Merged

chore(openspec): archive the last two changes, plus pymmseqs and Colab fixes#446
tsenoner merged 12 commits into
mainfrom
chore/reconcile-monorepo-openspec-change

Conversation

@tsenoner

@tsenoner tsenoner commented Aug 12, 2026

Copy link
Copy Markdown
Owner

OpenSpec housekeeping plus two small apps/protspace fixes that came out of it. Reconciles and archives the last two OpenSpec changes. Both shipped months ago and were left part-ticked, so openspec/specs/ had no record of guarantees the code has been enforcing since June.

Follow-up to #444, which archived the four changes that needed no judgement. These two did. After this, openspec/changes/ holds no active change for the first time in months.

1. merge-protspace-monorepo (was 16/30)

Two problems, either of which would have made a plain archive worse than leaving it alone:

Its delta would have created a competing capability. It declares bundle-contract; bundle-format-contract already exists, created a month later by add-bundle-contract-test. Two specs on one subject, nothing saying which is authoritative.

Its phase 5 was reversed, not skipped. The shipped design deliberately contradicts it:

This change's plan What shipped (bundle-format-contract)
Fixtures "SHALL commit golden fixtures" "SHALL NOT read any .parquetbundle checked into the repository"
CI trigger wire into the path-filtered CI "without a hand-maintained path filter — a filter is a copy of an import graph, and when it is wrong the job does not fail, it silently does not run"
Bundle shapes 3- and 4-part, no mention of statistics 3, 4 and 5-part, plus the zero-byte settings sentinel

So the section is marked superseded with both reversals recorded, rather than ticked. Every other open task was already done or moot: the freeze was never needed (1.4's deterministic re-sync handled it), #295 is closed, both publish paths have since run for real (4.11.1 to PyPI; the prep image on every PR), and tsenoner/protspace-legacy is archived: true with its last stale links repointed in #391.

schema.json is dropped by decision. With one producer and one consumer, both in-repo and both covered by a CI job that cannot be silently skipped, a declarative schema would be a third copy of the format to keep in step rather than a guarantee.

One requirement was alive and hiding. The delta's fourth requirement has nothing to do with bundles: prep must consume protspace from workspace source so a break surfaces in the same change, not after a release. It is already true — protspace = { workspace = true } — and was only "unfinished" because it names services/protspace-prep, the path the restructure itself renamed to apps/prep. It lands as prep-source-coupling.

2. unified-visibility-model (was 20/22)

No design call needed here — point-visibility collides with nothing, and every requirement in it is already pinned: the spec's eleven rules map one-to-one onto eleven describe blocks in visibility-model.test.ts (35 tests, 95 opacity assertions).

Both open boxes were manual checks, which leave no artifact. Rather than tick them because the code shipped nine weeks ago, each was re-checked against what is now automated:

  • 5.2 (no full re-sort on legend toggle) — superseded by automation. The invalidation contract is pinned by scatter-plot.lifecycle.test.ts and webgl-renderer.signature.test.ts, a stronger guarantee than the one-off manual observation the task asked for.
  • 5.3 (UX parity sweep) — mostly automated since: clickability is unit rule 11, zero-opacity is rule 1, isolate/reset are covered by three e2e specs.

3. pymmseqs floor raised to 1.2.0

pymmseqs 1.2.0 (published 2026-08-11, after #391 merged) fixes both reasons that dependency was moved into the similarity extra. Every release through 1.1.0 shipped cp310-only wheels, so against requires-python = ">=3.12" it compiled from sdist for every user on every platform; and it pinned ipython<9, which upgraded Colab's pinned ipython and produced the dependency-conflict wall in the notebook. 1.2.0 ships six py3-none-* wheels (macOS x86_64/arm64, manylinux and musllinux x86_64/aarch64) and drops both ipython and scikit-learn, leaving numpy/pandas/pyyaml — all already core.

The old floor was >=1.0.4, so a fresh install already resolved 1.2.0 and already got wheels. Raising the floor makes that a guarantee rather than an accident: a constrained or older resolver can no longer land on a cp310-only release and silently go back to compiling from source. The lock moves 1.1.0 → 1.2.0 so dev and CI match what users get.

It stays an extra. Both original arguments are gone, but it is reachable through exactly one flag and a smaller base install stands on its own. CLAUDE.md records that upstream fixed both problems and why the extra remains, so its rationale does not quietly become another stale claim.

The published upgrade note did need a correction, though — see §5.

Verified against the artifact rather than the metadata: installed 1.2.0 as a wheel and ran compute_similarity end to end on a 5-sequence FASTA — three single-residue variants score 0.974–0.987 mutually and 0.0 against two unrelated sequences, diagonal 1.0. The full 3.12/3.13/3.14 CI matrix is the real proof the py3-none wheels install everywhere supported.

4. esm2_3b is gated on what the runtime can actually hold

Closes #448. Closes #449.

esm2_3b failed partway through a Colab run that had already paid for setup and input handling, and the panel only warned, which does not stop the selection. The notebook already had the mechanism pointing the other way — _drop_incompatible drops esmc_* on Biocentral, whose ESM-C returns embeddings orthogonal to the real model — so both constraints now read from one table that drives the checkbox gating and the drop-at-Generate backstop. Previously those were separate code paths and had already diverged: the first knew one rule, the second knew both.

The gate keys on host memory, not on the model name. The first cut blocked esm2_3b on every Colab tier, including the paid L4/A100 runtimes where it fits — a capability that worked before this PR. It is now conditional:

Runtime Host RAM esm2_3b
Free 12.7 GB blocked
High-RAM / L4 / A100 51–83 GB available
Probe unavailable blocked

Host memory rather than VRAM because that is what actually decides. setup_model passes no dtype for ESM, so esm2_3b loads fp32 — 11.4 GB of weights, materialized on the host by from_pretrained before model.to(device). Against the free tier's 12.7 GB that is the binding constraint, and it is the worst failure available: a host-RAM blowout kills the kernel with no traceback, taking the fetched sequences with it, and setup_model sits outside the OutOfMemoryError backoff that only wraps _embed_batch. Running out of VRAM merely raises. A VRAM probe would also change no outcome on any current tier while building a CUDA context to do it.

The set stays identity-keyed rather than computed: esm2_3b is 10.59 GiB of weights, the runner-up (ankh_large, encoder-only) is 4.29 GiB, and nothing sits between them — so there is no boundary to tune, and a per-model GB column would rot silently across a transformers bump.

The remedy no longer names a service that may be down. prep-failure-routing guarantees a Biocentral outage routes users into this notebook, where auto resolves to local on a GPU runtime — and the panel answered by telling them to use Biocentral. The table's third field now carries a whole remedy instead of a backend name.

The same flow strands users more broadly, which is #449: a Colab runtime is CPU-only until a GPU is attached, so auto falls back to the very service that just failed, silently. The panel now discloses that fallback and names the fix, and the same hint joins the generation-failure troubleshooting list.

Still scoped to the notebook deliberately: these are limits of a hosted environment, not of the package. protspace prepare --backend local -e esm2_3b stays correct on real hardware, and the CLI enforces neither rule.

5. Corrections found while reviewing the above

Three defects in this PR's own earlier commits, plus one stale published claim:

  • The notebook would have died for every user. Cell 1 imported COLAB_OVERSIZED, a name added in the same commit — but cell 1 pip-installs the released protspace while the notebook is served from main, and 4.11.1 does not have it (verified against the actual wheel). Its ImportError handler raises SystemExit telling the user to restart the session. Both blocked sets now import behind a tolerant fallback, so main and PyPI are allowed to be out of step.
  • _ESMC was a bare notebook literal that nothing pinned. It moves to biocentral.py as BIOCENTRAL_INVALID, beside COLAB_OVERSIZED in local.py, and a test asserts each resolves to a real shortcut — a typo in either would have silently gated nothing. Each set is pinned to the registry it actually constrains (COLAB_OVERSIZEDLOCAL_CHECKPOINTS, BIOCENTRAL_INVALIDALL_SHORT_KEYS); they coincide today, so pinning both to Biocentral's would have made the first local-only oversized checkpoint fail a test whose message is about Biocentral shortcuts.
  • The blocked-reason named the wrong resource ("a Colab GPU", "~15 GB VRAM"). Corrected in the notebook, the package warning, and the comments.
  • The published similarity note was stale. It told -s users the base install "no longer compiles MMseqs2 from source". Scoped to the base install that stayed literally true, but after 1.2.0 the extra does not compile either — so an upgrade note telling people to install that extra warned about a cost that no longer exists on the path it recommends. Both synced copies (README.md, docs/guide/python-cli.md) move together; the sync test compares them line-for-line.
    The replacement needed a hedge of its own: 1.2.0 publishes six py3-none wheels and no win_amd64, so a flat "installs from a prebuilt wheel" is false on Windows, where pip still builds the sdist. Qualified to macOS and Linux.

6. embedding-backend-selection capability

Closes #450.

The local backend shipped through #320 with no OpenSpec change, so nothing in openspec/specs/ described it — grepping the corpus for esm / embedder / colab hits only prep-failure-routing's Colab-routing copy. That is why §4's two rules lived as literals in one notebook cell and drifted.

Authored spec-first per AGENTS.md and through the real workflow rather than hand-adding a file (all 19 existing specs were written by a commit that also archived a change): openspec new change → proposal/design/tasks/delta → validate --strict --type changearchive, as the last commit on the branch.

Six requirements: auto resolution and its failure modes, the fallback disclosure, gating-plus-backstop from one declaration with rejection before expensive input acquisition, the two compatibility rules, the capacity condition on the size rule, and the deliberate CLI non-enforcement.

Gaps carried forward, not closed

Not closed here, and deliberately: loading ESM/ESM-C in fp16 on GPU would halve esm2_3b to 5.29 GiB and let §4's size rule be deleted rather than conditioned. It changes numerics for every esm2_* and esmc_* key, so it needs the local↔Biocentral parity cross-check re-run and CLAUDE.md updated — and esm2_3b was never in that cross-check. Recorded in #448.

7. Cleanup pass over §4's gating

A review of the above found the gating correct but doubled, and the pin protecting it
weaker than advertised.

The duplication. The select/resolve/drop/bail block was written out in both the
query and fasta branches of _on_gen, and only the query copy was moved ahead of the
UniProt fetch. Extracted as _embedders_and_backend(), so the "reject before paying for
input" ordering is structural rather than a comment, and there is one copy to edit.

Alongside it: _INCOMPATIBLE's values drop from a 3-tuple to a 2-tuple (why and
remedy were stored apart but re-joined with the same "; " at both call sites);
_host_ram_gb() becomes _ram_below_gb(gb), returning the boolean actually wanted
instead of a float plus a magic 0.0 "unreadable" sentinel; the two red-<span>
branches append plain text to one list wrapped once; and _INCOMPATIBLE[eff] becomes
.get(...), since an unknown backend blocked nothing anyway but used to raise
KeyError at panel-build time and take the whole control panel down with it.

The pin had the failure mode it existed to prevent. The new notebook-fallback guard
asserted only inside a loop over whatever its matcher found, so when the matcher found
nothing it passed green. Three natural edits silently disarmed it: writing the emptied
fallback as frozenset() (zero args), using the package's own annotated-assignment
style, or deleting the fallback outright. It also called raw ast.parse on cell source
— in the one test module whose entire reason for existing is that notebook cells are not
plain Python, and ProtSpace_Transfer.ipynb already has a !protspace transfer cell
that raw parse cannot read.

Rebuilt to key off the try/except structure rather than scanning for assignments, so
a missing fallback is a failure instead of an empty result, and to parse
IPython-transformed source. Mutation-tested, notebook restored byte-identically after
each:

Mutation Before After
stale non-empty literal caught caught
emptied via frozenset() passed caught
annotated (package style), wrong passed caught
bare set literal, wrong passed caught
fallback deleted outright passed caught
correct, written differently passed passed

local.py's warning generalised to %s but still hardcoded esm2_3b's ~11.4 GB, so a
second name in COLAB_OVERSIZED would have emitted a wrong figure; dropped. Behaviour is
otherwise preserved — the KeyError hardening is the one intentional change — verified by
executing the rewritten gating against stub widgets across auto/local/biocentral/unknown.

Bookkeeping fixes found reviewing the archive. #66/#55/#60 in the monorepo
change's reconciled tasks are protspace-legacy PRs, but bare #N autolinks against
this repo — and #66 is the silent case, resolving to a merged, unrelated frontend PR
("enhance StructureService with 3D Beacons API integration") instead of 404ing, so the
link renders fine and points somewhere wrong. Qualified; #306/#295/#233/#445 are
genuine refs here and stay bare. design.md still described _INCOMPATIBLE as
(blocked set, why, remedy) because the archive commit predates the refactor above. And a
5.3 checkbox was ticked while its own text read "carried forward to #447 rather than
ticked" — reworded so the box and the sentence agree.

Notes

proposal.md and design.md keep the superseded plan as history with the supersession marked at the top of each affected section — archiving freezes them, and an unmarked stale rationale reads as intent.

All three new capabilities got a real ## Purpose. openspec archive writes a TBD - update Purpose after archive placeholder that passes validate --strict, so it survives unless somebody looks — which is how two from an earlier archive sat there until #444.

Verification

openspec validate --all --strict → 20 passed, 0 failed, re-run after precommit's prettier pass (it rewraps markdown, and --strict only scans a requirement's first line for the normative clause). openspec list → no active changes. pnpm precommit green on every commit. uv run pytest -m "not slow" → 825 passed.

The gating was exercised across the full matrix — released-lag × capacity × both backends — and renders identically whether or not the installed package carries the new constants.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5

tsenoner and others added 2 commits August 12, 2026 18:32
The migration shipped months ago; its checklist never caught up. Sixteen of thirty
boxes were ticked, and the rest were a mix of done-but-unticked, moot, and — for
all of phase 5 — superseded. Archiving it as-is would have frozen that as the
record and landed a `bundle-contract` capability directly alongside the
`bundle-format-contract` that already exists, with nothing to say which is
authoritative.

Reconciled each open task against reality rather than ticking them off:

- 0.1-0.3, 2.8, 3.4, 4.2, 4.3: done or moot. The freeze was never needed (the
  deterministic re-sync in 1.4 handled it), #295 is closed, both publish paths
  have since run for real (4.11.1 to PyPI, prep image on every PR), and
  `tsenoner/protspace-legacy` is archived with its last stale links repointed
  in #391.
- Phase 5: superseded by `add-bundle-contract-test` (archived 2026-07-20), which
  reached the same goal by a different route. Two of its decisions were not
  skipped but reversed, and the section now records that: golden fixtures are
  generated per run, never committed ("a committed fixture goes stale silently"),
  and the CI job deliberately has no hand-maintained path filter ("a filter is a
  copy of an import graph, and when it is wrong the job does not fail, it
  silently does not run").
- The `schema.json` idea is dropped by decision. With one producer and one
  consumer, both in-repo and both covered by an unskippable CI job, it would be a
  third copy of the format to keep in step rather than a guarantee.

The delta is replaced accordingly. Its three bundle requirements are gone; the
fourth had no equivalent anywhere and is already true, so it archives on its own
as `prep-source-coupling`: prep depends on protspace as a uv workspace source, so
breaking a consumed API fails prep's tests in the same change. It was only
"unfinished" because it was written against `services/protspace-prep`, the path
the restructure renamed to `apps/prep`.

Task 5.6 turned out to be a real gap rather than superseded: the TS side writes
`.parquetbundle` and `protspace transfer -b` / `style` read one, so the
web-export to CLI flow exists — but the contract only covers Python-write to
TS-read. Filed as #445 and carried forward, so the archive does not bury it.

proposal.md and design.md keep the superseded plan as history with the
supersession marked, since archiving freezes them and an unmarked stale rationale
reads as intent.

Verified: `openspec validate --all --strict` 19/19; `openspec list` now shows only
`unified-visibility-model`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
The last unarchived change, and the same story as merge-protspace-monorepo: the
work shipped nine weeks ago, but two boxes stayed open, so the eleven visibility
rules it established were absent from `openspec/specs/`.

Unlike merge-protspace-monorepo this one needed no design call — `point-visibility`
collides with nothing, and every requirement in it is already pinned: the spec's
eleven rules map one-to-one onto eleven `describe` blocks in
`visibility-model.test.ts` (35 tests, 95 opacity assertions).

Both open boxes were *manual* checks, which leave no artifact, so rather than tick
them on the strength of the code having shipped, each was re-checked against what
is now automated:

- 5.2 (fast-path, no full re-sort on legend toggle) is superseded by automation:
  the invalidation contract is pinned by `scatter-plot.lifecycle.test.ts` and
  `webgl-renderer.signature.test.ts` — a stronger guarantee than the one-off
  manual observation the task asked for.
- 5.3 (UX parity sweep) is mostly automated since: clickability is unit rule 11,
  zero-opacity is rule 1, and isolate/reset are covered by three e2e specs.

Two items of 5.3 nothing replaced: hide-vs-query axes re-fit, and PNG export
excluding hidden points. Neither has any regression coverage — the only `axes`
match under `apps/web/tests` is an unrelated PNG-chunk comment, and
`figure-editor.spec.ts` never mentions hidden state. Filed as #447 and carried
forward rather than ticked on the assumption that shipped means verified.

`openspec/changes/` is now empty of active changes for the first time in months.

Verified: `openspec validate --all --strict` 19/19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
@tsenoner tsenoner changed the title chore(openspec): reconcile and archive merge-protspace-monorepo chore(openspec): reconcile and archive the last two changes Aug 12, 2026
tsenoner and others added 2 commits August 13, 2026 11:44
`pymmseqs` 1.2.0 (2026-08-11) fixes both reasons this dependency was moved into
the `similarity` extra. Every release through 1.1.0 shipped cp310-only wheels, so
against this package's `requires-python = ">=3.12"` it compiled from sdist for
every user on every platform; and it pinned `ipython<9`, which upgraded Colab's
pinned ipython and produced the dependency-conflict wall in the notebook.

1.2.0 ships six `py3-none-*` wheels (macOS x86_64/arm64, manylinux x86_64/aarch64,
musllinux x86_64/aarch64) and drops both `ipython` and `scikit-learn`, leaving
numpy/pandas/pyyaml — all already core deps.

The old floor was `>=1.0.4`, so a fresh install already resolved 1.2.0 and already
got wheels. Raising the floor makes that a guarantee rather than an accident: a
constrained or older resolver can no longer land on a cp310-only release and
silently go back to compiling from source. The lock moves 1.1.0 -> 1.2.0 so the
dev environment and CI match what users get.

Keeping it an extra regardless. The wheel and ipython arguments are gone, but it
is still reachable through exactly one flag, and a smaller base install stands on
its own — so this does not reverse the upgrade note published hours ago in the
README and the CLI guide, which stays correct.

Verified against the real thing rather than the metadata alone: installed 1.2.0 as
a wheel and ran `compute_similarity` end to end on a 5-sequence FASTA. Three
single-residue variants score 0.974-0.987 mutually and 0.0 against two unrelated
sequences, diagonal 1.0 — the matrix the pipeline expects. 862 tests pass,
`uv sync --locked` is green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
esm2_3b does not fit a Colab GPU — a free T4 has 16 GB — so selecting it with the
Local backend fails partway through a run that has already paid for setup and
input handling. The panel only warned ("esm2_3b may not fit a free Colab T4"),
which does not stop the selection.

The notebook already had the mechanism for exactly this, pointing the other way:
`_drop_incompatible` drops esmc_* on the Biocentral backend, because Biocentral's
ESM-C returns embeddings orthogonal to the real model. Generalized it to filter in
both directions rather than adding a second, separate guard, since the two
constraints are complementary: esm2_3b is Biocentral-only, esmc_* is Local-only,
and each message names the backend that does work.

Scoped to the notebook on purpose. This is a Colab memory limit, not a package
one: `protspace prepare --backend local -e esm2_3b` is correct on real hardware,
so neither the CLI nor the published guide changes.

Verified both directions against the real function lifted out of the notebook:
local drops esm2_3b and keeps esmc_600m, biocentral drops esmc_600m and keeps
esm2_3b, and neither fires when nothing is incompatible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
@tsenoner tsenoner changed the title chore(openspec): reconcile and archive the last two changes chore(openspec): archive the last two changes, plus pymmseqs and Colab fixes Aug 13, 2026
The esm2_3b block landed in _drop_incompatible only, which is the backstop —
not _sync_backend_ui, which is the primary enforcement ESM-C already had. So
the model stayed checkable under Local and vanished silently at Generate, and
in the UniProt-query branch it did so *after* the sequence download, throwing
away a full fetch. Both directions now read one _INCOMPATIBLE table, so the
checkbox gating and the drop-at-Generate backstop cannot disagree, and the
resolve+filter moves above the fetch so an incompatible selection is rejected
before it costs anything.

The size rule itself was written twice with contradicting verdicts: local.py
said "may exceed a free Colab T4, switch to an L4/A100", the notebook said
"does not fit, use Biocentral". It lands in local.py as COLAB_OVERSIZED, which
the notebook imports and a test pins to real short keys — a typo there would
silently gate nothing, which is the failure the set exists to prevent.

The help text no longer restates the rule statically (it was true for one
backend only); it points at the backend note, which is live. Also hoists
_DOCS_CLI, since the same URL was split mid-path in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
tsenoner and others added 7 commits August 13, 2026 16:43
…ad service

Two defects in the esm2_3b gating, both found reviewing the previous commit.

Cell 1 imported COLAB_OVERSIZED, a name added in the same change. But cell 1
pip-installs the *released* protspace while the notebook is served from main,
so on first run the installed package does not have it yet — verified against
the real 4.11.1 wheel, which still carries `if embedder == "esm2_3b"`. Cell 1's
ImportError handler raises SystemExit telling the user to restart the session,
which is both the wrong diagnosis and the wrong blast radius for one optional
constant. It moves to cell 2 behind a try/except with a literal fallback, so
main and PyPI are allowed to be out of step.

"use the {other} backend" was the only remedy offered, and for esm2_3b it is
wrong in the very flow that sends people to this notebook: prep-failure-routing
guarantees a Biocentral outage routes to Colab, where Auto resolves to local on
a GPU runtime — so the panel answered an outage by recommending the service that
just failed. The table's third field now carries a whole remedy rather than a
backend name, which keeps one table and one render path while letting the local
row lead with advice that holds regardless of Biocentral's health.

Also corrects the resource: esm2_3b loses to host RAM (11.4 GB of fp32 weights
against the free tier's 12.7 GB), not to T4 VRAM, and it does so inside
setup_model, which sits outside the OOM backoff — so it kills the kernel rather
than raising. The gate stays identity-keyed: the runner-up is 4.29 GiB, a 2.5x
gap with nothing in between, so there is no threshold to tune. That it also
blocks paid runtimes where the model fits is real, and is issue #448.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
… note

The upgrade note told `-s` users that the base install "no longer compiles
MMseqs2 from source". Scoped to the base install that stayed literally true, but
pymmseqs 1.2.0 ships py3-none wheels, so the extra does not compile either — and
in an upgrade note aimed at people who are being told to install that extra, the
clause warns about a cost that no longer exists on the path it recommends.

Replaced with what this PR actually delivers. Both copies move together; the
sync test compares them line-for-line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
…o fallback

Closes #448, closes #449, closes #450.

Spec first: adds the embedding-backend-selection capability, which had no home
at all — grepping the spec corpus for esm/embedder/colab hit only
prep-failure-routing's Colab-routing copy. The local backend shipped through
#320 without an OpenSpec change, so the two compatibility rules lived as
literals in one notebook cell, in two code paths that had already diverged.

#448: the size rule blocked hardware that works. It keyed on model identity and
never asked what the runtime had, so esm2_3b was refused on every Colab tier —
including the paid L4/A100 runtimes where it fits, and where it worked before
this PR made the gate hard. It is now conditional on host memory.

Host memory, not VRAM, because that is what actually decides: an oversized
checkpoint is materialised on the host before it reaches the GPU (esm2_3b loads
fp32 — setup_model passes no dtype for ESM — so 11.4 GB against the free tier's
12.7 GB), and losing that race kills the kernel with no traceback, inside
setup_model, which sits outside the OOM backoff. Running out of VRAM merely
raises. A VRAM probe would also decide nothing extra on any current tier while
building a CUDA context to do it. The set stays identity-keyed: the runner-up is
4.29 GiB against esm2_3b's 10.59, so there is no boundary to tune.

#449: a free Colab runtime is CPU-only until a GPU is attached, so auto resolves
to biocentral — and this notebook is where a Biocentral outage sends people. The
default path handed them straight back to the service that had just failed,
silently. The panel now says so and says what changes it, and the same hint
joins the generation-failure troubleshooting list.

Also moves _ESMC into the package as BIOCENTRAL_INVALID beside COLAB_OVERSIZED,
so both blocked sets are declared once and pinned by a test — a typo in either
would have silently gated nothing. Both are imported behind the tolerant
fallback, since the notebook is served from main but installs the released
package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
…the fallback

The gating landed correct but doubled: the select/resolve/drop/bail block was
written out in both the query and fasta branches of _on_gen, and only the query
copy got moved ahead of the UniProt fetch. Extract _embedders_and_backend() so
the ordering is structural and there is one copy to edit.

Alongside it, shed the scaffolding the second constraint added:

- _INCOMPATIBLE values 3-tuple -> 2-tuple; `why` and `remedy` were stored apart
  but re-joined with the same "; " at both call sites.
- _host_ram_gb() -> _ram_below_gb(gb), returning the boolean actually wanted
  instead of a float plus a magic 0.0 "unreadable" sentinel. _OVERSIZED_MIN_RAM_GB
  folded in, so 16 sits under the comment justifying it.
- The two red-<span> message branches append plain text to one list, wrapped once.
- _INCOMPATIBLE[eff] -> .get(...): an unknown backend blocked nothing anyway, but
  it used to raise KeyError at panel-build time and take the whole control panel
  down with it.
- Drop ~14 lines of comment that restated rationale already living at the package
  definition sites.

local.py: the warning generalised to `%s` but still hardcoded esm2_3b's ~11.4 GB,
so a second name in COLAB_OVERSIZED would have emitted a wrong figure. The set's
comment also said "free Colab GPU (~15 GB usable VRAM)" while the mechanism
everywhere else is host RAM.

Tests: pin each blocked set to the registry it constrains rather than to whichever
was handy — COLAB_OVERSIZED is a fact about LOCAL_CHECKPOINTS, so a local-only
oversized checkpoint must not have to be a Biocentral shortcut to be gated.

And pin the notebook's `except ImportError` literals, which nothing checked. That
copy is the one that runs during the released-package lag it exists for, so
emptying BIOCENTRAL_INVALID or adding to COLAB_OVERSIZED would have left the panel
gating on stale policy silently. Read structurally off the guarded import so a
fallback that is missing, emptied, or written as an annotated assignment / bare set
literal fails rather than matching nothing — a guard that can silently disarm
itself is worse than none, since the notebook comment claims protection either way.
Parses IPython-transformed source, because ProtSpace_Transfer.ipynb already has a
`!protspace transfer` cell that raw ast.parse cannot read.

Behaviour-preserving except the KeyError hardening, verified by mutation testing
each drift case and by executing the rewritten gating against stub widgets for
auto/local/biocentral/unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
Raising the floor to 1.2.0 does guarantee a prebuilt wheel, but not everywhere:
1.2.0 publishes six py3-none wheels — macosx 10.15 x86_64, macosx 11.0 arm64,
manylinux x86_64/aarch64 and musllinux x86_64/aarch64 — and no win_amd64, so pip
on Windows still falls back to the sdist and compiles.

Both copies edited identically; test_docs_extras_sync pins them line for line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
Three bookkeeping fixes found reviewing the archive:

- `#66`/`#55`/`#60` in the monorepo change's reconciled tasks are protspace-legacy
  PRs, but bare `#N` autolinks against this repo. `#66` is the silent case: it
  resolves to a merged, unrelated frontend PR ("enhance StructureService with 3D
  Beacons API integration") rather than 404ing, so the link looks fine and points
  somewhere wrong. The intended one is "bundle format v2 — lossless annotation name
  encoding", which is what the surrounding text describes. `#306`/`#295`/`#233`/`#445`
  are genuine refs here and stay bare.
- design.md still described `_INCOMPATIBLE` as (blocked set, why, remedy); 3cdacf9
  collapsed it to (blocked set, note). The archive commit landed before that refactor
  and nothing re-reconciled after.
- A 5.3 checkbox was ticked while its own text read "carried forward to #447 rather
  than ticked". Reworded so the box and the sentence agree.

openspec validate --all --strict: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
…e impact list

Two AGENTS.md conformance fixes on the previous commit and the archive:

- 53b231f expanded every `#66`/`#55`/`#60` mention inline. AGENTS.md asks for the
  opposite: qualify once on a definitional line and keep the body bare, since full
  qualification turns headings into `### 2.3 tsenoner/protspace-legacy#57: ...`. The
  body goes back to bare `#N` and a `**Refs:**` line in the header blockquote carries
  the disambiguation.
- "Archive before the merge" asks that proposal.md and design.md be reread against the
  final diff, because archiving freezes them. The archive commit predates the cleanup
  in 3cdacf9, so its Impact list named neither test file nor CLAUDE.md and described
  a gating shape that no longer exists. Both recorded; the requirements are unchanged
  by the refactor.

openspec validate --all --strict: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
@tsenoner
tsenoner merged commit 226bd57 into main Aug 13, 2026
11 checks passed
@tsenoner
tsenoner deleted the chore/reconcile-monorepo-openspec-change branch August 13, 2026 16:35
tsenoner added a commit that referenced this pull request Aug 13, 2026
… wrong

"For anything else, dispatch it" reads as: every docs-only and Python-only PR
should fire a full Playwright suite. #446 did exactly that — it touched
apps/protspace, docs/, openspec/ and uv.lock, no TS/JS and none of the root
resolution files, so it could not reach the web app at all, and the dispatched
run spent ~10 minutes confirming that.

The intent is worth keeping: e2e.yml's own comments are emphatic that a wrong
`paths:` filter does not fail the job, it silently never runs, which reads like a
pass. That argues for covering what the filter might *miss*, not for dispatching
unconditionally. The "not whenever `paths:` simply didn't match" clause is there so
the narrower rule does not read as an oversight and get widened back.

Nothing about the workflow changes; e2e.yml still owns the paths list. Net +5 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ar6ME3o6VYLYsie3J4kxE5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant