Skip to content

Add /search, /xref and /combine to the HTTP API, plus a lightweight client and a docs site - #82

Open
Robbie1977 wants to merge 13 commits into
mainfrom
feature/vfbconnect-http-api
Open

Add /search, /xref and /combine to the HTTP API, plus a lightweight client and a docs site#82
Robbie1977 wants to merge 13 commits into
mainfrom
feature/vfbconnect-http-api

Conversation

@Robbie1977

@Robbie1977 Robbie1977 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Turns VFB Connect–style access into an HTTP service by extending the existing v3-cached API, so the workshop notebooks, the website and the MCP can all call one thing instead of three.

What's in it

Three new endpoints on ha_api:

  • GET /search — the website's own ranking, server-side. The fq, boosts and the JS comparator were adopted as canonical rather than reinvented, and the sorter was ported so results come back in the order a user already recognises from virtualflybrain.org.
  • GET /xref — VFB ID ↔ external database ID in both directions.
  • GET /combine — bracketed set algebra over query results (A ∪ B, (A ∩ B) − C, …) with plain-English explanations for people who don't think in logic, and column merging that loses no data from either side. Documented at length in docs/combine-endpoint.md with biological worked examples.

/query_connectivity now expands a neuron type over its subclasses, which is the difference between an answer and an empty table. Kenyon cell has zero directly-typed individuals — all ~16,000 hang off its 38 subclasses — so DA1 lPN → Kenyon cell returned nothing at all, which reads as "these cells are not connected" rather than "you named a parent class". It now returns 1,504 connections (1,984 with exclude_dbs=[]), and the response carries a resolved block saying what each label was taken to mean and how far the expansion reached.

The query is driven from whichever side has fewer individuals; expanding both sides into variable-length walks does not complete, and the dataset filter had to move from a trailing MATCH to an EXISTS predicate placed right after the anchor's WITH DISTINCT — 70 s to 0.4 s on the same query, same rows. Verified both anchor directions return identical counts.

Alongside it, DEFAULT_EXCLUDE_DBS now carries why hb and fafb are excluded (double-counting, not data quality: FAFB and FlyWire reconstruct the same EM volume; hemibrain overlaps in type with FlyWire and male-CNS without being the same cells) — in the constant, in docs/http-api.md, and in the client README and docstring. The client also gained the exclude_dbs and group_by_class parameters it needed for that to be actionable, and either type may now be omitted for the one-sided question.

/get_hierarchy is now in ALLOWED_PATHS and documented — it is an ordinary query and was private only because nobody had asked. /get_hierarchy_html stays in-cluster: it is the pre-rendered markup the geppetto ROI browser consumes, and publishing one consumer's HTML as API surface buys nothing the JSON doesn't give.

clients/vfbquery-client/ — a lightweight client (requests + pandas, no navis) that installs in seconds, which is what makes it usable from a Colab cell in a workshop.

A Sphinx documentation site (docs/, .readthedocs.yaml) built from the markdown that already existed, wired to https://vfbquery.readthedocs.io. README.md is left alone — it's generated by the GA scripts, and the site copies it in at build time rather than replacing it.

Tests — ~2,700 lines across combine, the endpoints, xref, shed/coalescing and resilience.

How it was verified

scripts/check_gates.sh runs five gates and all five are green:

gate what it proves
unit /xref shaping, shed/coalescing and resilience, offline
client request shaping, offline
parity 78 queries ordered identically to the website JS — run against the real comparator in a geppetto-vfb checkout, not a description of it
recall the exact-label boost: recall +1, rank-0 +1, 0 positions of churn in the top 10, same number of Solr requests
live the client against a server started from this checkout

Docs build clean under -W for both HTML and LaTeX, and make produces a 126-page PDF.

Needs doing on merge — please read

The two CI workflows are parked in docs/ci/, not .github/workflows/. The credential this branch was pushed with has no workflow scope, and GitHub rejects such a push wholesale. They're complete and one git mv from live:

git mv docs/ci/search-gates.yml .github/workflows/search-gates.yml
git mv docs/ci/docs.yml         .github/workflows/docs.yml

Until that happens, CI does not run on this PR — the gate results above are from a local run. docs/ci/README.md explains what each does.

Read the Docs is also red right now and will stay red until this merges: latest tracks main, and main has no .readthedocs.yaml yet. The RTD GitHub webhook is not connected either, which only affects automatic builds.

Behaviour changes worth a second look

  • Error contract. Removing tracebacks from responses changed detail on pre-existing endpoints to "ExcType: message". Anything parsing that string will notice.
  • Client 4xx. The client now raises VfbError rather than requests.HTTPError for a 4xx carrying a JSON error body.
  • Client read timeout 60 s → 180 s. Subclass expansion makes some questions large: Tm1 → T3 is 4,915 individuals against 5,430 and takes 30–60 s on a cold cache, which timed the old default out. The server caches the answer so only the first caller waits, but timing that caller out turns a slow query into an apparent outage. The live gate's connectivity case moved to the giant fiber → PSI pair (8 individuals against 6, about a second) for the same reason: that gate is there to prove weight reaches the server, not to time Neo4j.
  • PDF-only source transforms in docs/conf.py elide code blocks over 80 lines and translate emoji ([OK]). The HTML remains the complete version; the transforms exist because a 1,100-line JSON example overflows a TeX box and kills the build outright.

Still out of repo

Deploying /search, /xref and /combine (all 404 on v3-cached today); repointing the website's datasourceConfiguration and the MCP's search_terms at /search; publishing vfbquery-client to PyPI. The VFB_Preview MCP's query_connectivity tool description also lives in another repo and now understates what the endpoint does — it should mention that a type covers its subclasses and that two datasets are excluded by default.

Robbie Court and others added 13 commits July 23, 2026 21:16
…earch & /xref endpoints

- docs/vfbconnect-http-api-plan.md: full plan, vfb_connect->query_type map, phasing, 80-peak scaling
- clients/vfbquery-client: pure requests+pandas client over v3-cached (no navis); tested live (68 DA1 lPN instances, 107 NBLAST hits)
- docs/draft_search_xref_endpoints.py: reviewable draft handlers for /search (C1) and /xref (C3)
Decisions: lightweight standalone package; extend v3-cached paths.
…t resolve_entity

- Client: search() now runs the exact MCP search_terms edismax query (ontology core, wildcards, mm=45%, qf autosuggest, bq boosts, filter/exclude_types); name->id resolution uses it, resolve_entity dropped. Tested live: 'DA1 lPN' -> FBbt_00067363, get_instances(by name) -> 68 rows.
- Draft /search endpoint: corrected to ontology core + real search_terms params; note to share one config with MCP/website (no drift).
- Plan C1: reframed as 'expose existing search_terms', with why resolve_entity is the wrong tool.
…e wiring /search

Checked geppetto-vfb as directed: five live copies of the free-text search config, all
against the Solr ontology core, but the website is NOT sending the same query as the MCP
search_terms tool. Differences measured live and factors isolated:

- q: website ANDs a wildcard OR-group per token (geppetto-client getResultsSOLR);
  search_terms wildcards the whole phrase and leans on mm=45%. DA1 lPN 51 vs 718 hits.
- fq: website hard-excludes facets_annotation:Deprecated; MCP only demotes it. This (not
  the tokenisation) is what makes a bare hemibrain bodyId resolve to the right neuron.
- bq: website floats Class^200/FBbt^150 over VFB^50 plus DataSet^500/pub^100.
- website then explodes synonyms into rows and re-ranks with a ~350-line custom sorter, so
  users never see raw Solr order - serving 'what the website does' means porting that too.
- two copies (spotlight, geppetto-client default) still use the pre-migration schema and
  one points at solr-dev.

Adds docs/search-config-comparison.md + a reproducible docs/compare_search_configs.py,
records the open decision in the plan (blocks wiring /search), and makes the client's q
construction switchable (q_mode='phrase' = MCP parity, default; 'tokens' = website) so no
behaviour changes until the canonical config is agreed.
Search ranking currently exists in five drifted copies (the website's
searchConfiguration.js, the geppetto-client default, the MCP, and two
others - see docs/search-config-comparison.md). This collapses them to
one: search_config.py ports the website's query construction, fq, boost
chain and ~370-line comparator, and /search serves it, so the website,
the MCP and the Python client can all call the same endpoint and get the
same order.

The port is bug-compatible on purpose, including JS
String.replace(str, str) first-occurrence-only semantics and the empty
OR-group a double space produces (measured: 3 hits with it, 0 without,
so it is not inert). The comparator is not a consistent total order, so
identical ordering cannot be guaranteed by construction and has to be
measured: docs/search-parity/ runs the real sorter and refineResults
under Node against the same Solr docs and diffs the ordering. Currently
byte-identical on 78 queries (22 hand-picked discriminating cases plus a
56-query fuzz sample), including 2432-row result sets, empty input,
braces, quotes and apostrophes. Re-run it after touching the ranking.

/search deliberately bypasses the Neo4j ProcessPool. Its cost profile is
the opposite way round - 150 ms-1.2 s of Solr I/O plus 6-300 ms of
Python ranking, with no Neo4j leg - so occupying a pool worker to wait on
a socket would both cap search throughput and queue it behind slow
connectivity queries, which is backwards: search is how people find the
IDs they feed to everything else, so it has to stay responsive while the
rest of the box is busy. Instead Solr is awaited on the event loop over a
shared keep-alive session and only the ranking is offloaded, to threads
rather than processes because pickling ~2000 dicts would cost more than
the sort.

Concurrency is bounded but queued rather than refused. An availability-only
check shed half of a burst of 80 distinct queries with 503 even though it
drained in under 4 s; waiting up to search_queue_wait fixes that while
still shedding genuine overload. Measured at the workshop target of 80
peak users: 80 concurrent distinct queries all succeed at p50 2.4 s /
max 3.5 s, 160 concurrent still all succeed at max 3.9 s, and the
realistic case - many people on the same handful of exercise queries -
is almost entirely cache hits and coalesced fetches (31 ms p50, >1000
req/s).

Verified end-to-end through HTTP against the module-level search() on 15
cases covering rows, limit and all four type filters: identical rows and
counts. Cache (611 ms -> 1 ms), coalescing (8 identical concurrent
requests -> 1 Solr fetch), the 503 shed path with Retry-After, and the
four 400 paths all exercised against a running server.
The client carried its own edismax config — its own q construction, fq,
qf and bq — which made it a sixth copy of a search configuration that was
already drifting in five places, and it ranked by raw Solr score, so it
disagreed with the website on almost every query. All of that is now
server-side: search() is a call to /search, and there is no Solr
configuration left in the client at all.

Two semantic corrections fall out of the move. `rows` is a *ranking*
parameter on /search (the comparator can only promote what Solr returned),
so page size is now `limit` and `rows` defaults to the website's 500 —
previously `rows=50` was silently limiting ranking depth to 50 candidates.
And _resolve_to_id asked for rows=1, i.e. one *unranked* candidate; it now
asks for limit=1, the top of a fully ranked list. Verified against a local
/search: 'DA1 lPN' -> FBbt_00067363, bare hemibrain bodyId '1734350908'
-> VFB_jrchjtdb.

filter_types/exclude_types are now passed through rather than
reimplemented, and boost_types/demote_types are newly exposed.

Tests cover what the client is now responsible for — the parameter names
it sends, that empty filters are omitted, that candidate depth does not
collapse to `limit`, and that ids skip the lookup — plus a live test that
asserts the class outranks the individuals for 'DA1 lPN', since that
ordering is the point of the endpoint and a plain Solr query does not
produce it.
The analysis these docs recorded was written from reading five config files.
Validating the port measured several of its claims false, so this rewrites them
rather than appending caveats.

Corrections:

* pf explains nothing, in any config. `pf=true` is a silent no-op — Solr reads
  pf as a field list, so `true` is a field name that does not exist — and even a
  correct field list never fires against the website's wildcard OR-group q,
  because the expansion leaves no contiguous phrase to boost. Both measured;
  `compare_search_configs.py --check-pf` reproduces them, so the claim stays
  falsifiable instead of becoming folklore.
* The phrase boost that does the work is appended to bq at request time by the
  website's client (`label:"<input>"^3000 synonym:"<input>"^1500`), so it appears
  in no config file. Reading the configs cannot find it.
* MBON-a2's top hit is an individual and that is correct. The earlier claim that
  the website leads with the class was wrong: FBbt_00047966's synonyms are
  ["larval MBON-a2", "odd", "MBE7b"], so the class has no MBON-a2 match to boost.
  The fix is an ontology synonym, not a search change.
* JS `replace(string, string)` replaces only the first occurrence, so
  "MBON-a2-b" normalises to "MBON a2-b". The port is bug-compatible.
* There are six live configs, not five. VFBCircuitBrowser has its own
  web-worker sorter and no Class boost, so individuals lead there deliberately.

The load-bearing one: raw Solr order is not what a user sees. Every hit goes
through refineResults and a ~370-line comparator first, so any top-hit
comparison at the Solr level — including earlier revisions of both this document
and this script — describes something nobody experiences. The script now ranks
with the real ported sorter and labels the unranked order as candidates.

That is also why C1's "S — expose existing, don't build" estimate was wrong: the
query is one of three stages, and the two that decide ordering lived only in JS.

Open question 0 (canonical search config) is now decided in favour of the
website's, with the consequence recorded: until the website and the MCP call
/search, this is a fourth consumer agreeing with the website rather than a
single source of truth. The exact-label recall bug (`neuron` absent from all
1370 ranked rows, pushed *down* by bq) is inherited deliberately — the port is
faithful and the config has the bug, and changing VFB search behaviour wants
sign-off rather than a quiet fix inside a port.

Test plan section now carries the measured numbers, including the 80-concurrent
result that only running it revealed: the first implementation refused rather
than queued and returned {503: 40, 200: 40}.
The `neuron` case in comparison.md §5 was described with a mechanism that
measurement does not support, and two of its factual claims were wrong.

What is actually happening: the label and synonym halves of the runtime phrase
boost are additive. `label:"neuron"^3000` alone puts FBbt_00005106 at score-rank
0 — correct. Adding `synonym:"neuron"^1500` drops it to 705, because competitors
carry the token in their labels AND their synonyms (`dopaminergic neuron` has
`DA neuron`) and so collect 4500, while the term whose label *is* the query has
only `nerve cell` and collects 3000. The exact match is penalised for lacking a
redundant synonym. The boost is not unselective; it is selective on the wrong
thing.

Corrected, both falsified by checking the schema rather than reading it:
- the schema *does* have an exact field, `label_str` (strings, docValues,
  case-sensitive), contradicting "no label_s string field";
- `label_autosuggest_e` is not the intended exact field — text_general,
  tokenized identically to `label`, and unpopulated for the failing document.

Scope, which the old text left open: 42/43 sampled labels already retrieve their
own term and 41/43 rank 0, and a 70-label single-token sweep found no further
failures. `neuron` is the only real miss. But `adult` (score-rank 424 of 500)
and `cell` (137) are latent, so this is one indexing change from spreading.

Adds check_recall.py, which measures what the parity gate structurally cannot:
parity feeds both sorters the same Solr response, so it is blind to a term that
never reaches either. Four candidate fixes measured; `label_str:"<term>"^6000`
appended to `bq` fixes the miss with zero top-10 churn and no extra request.

search_config.py is deliberately untouched. Retrieval changes what every
consumer of /search sees and the parity gate cannot catch a regression in it,
so this is a recommendation awaiting sign-off, not a change.
Fixes from an adversarial review of the whole branch, plus the CI and
harness gaps that review exposed.

Service (src/vfbquery/ha_api.py)
- 500 bodies no longer ship a traceback. All six sites (get_term_info,
  run_query, _dispatch_to_pool, handle_search, handle_xref -- owner and
  coalesced-waiter halves each) go through a new _failure_response, which
  logs the traceback server-side and returns "ExcType: message". NOTE:
  this changes the error contract of pre-existing endpoints, not only the
  two this branch adds. The five coalesced-waiter halves previously never
  logged at all, so a failure a client saw left nothing on the server.
- /xref cache key is now injective. accession and db are free text, so
  "|".join was not: ?accession=A|B and ?accession=A&db=B| both flattened
  to one key answering two different questions. Now json.dumps.
- on_cleanup uses .get() throughout and suppresses CancelledError.
  aiohttp runs on_cleanup even when on_startup raised part-way, so
  indexing there replaced the real startup exception with a KeyError.
- _cap_result_rows caps every over-cap key in PAYLOAD_LIST_KEYS, not just
  the first, and derives `count` from the payload's primary list.

Search (src/vfbquery/search_config.py)
- build_exact_label_boost also emits un-normalised variants. normalise
  replaces the first -/_/+ with a space, but label_str holds the label
  verbatim, so the boost could not fire for the whole hyphenated and
  underscored half of VFB naming (MBON-a2, fan-shaped body). Recall gate
  confirms this moved nothing else: churn 0, 53 vs 53 Solr requests.

Parity harness (docs/search-parity/)
- A query returning zero docs now FAILS. [] == [] was being scored as a
  pass -- the one failure shape a parity harness must never call success.
- That guard immediately caught a real hole: fuzz_queries drew its pool
  with no fq under sort=short_form asc, i.e. the alphabetical head of the
  unfiltered index, which is the imported BFO_/CHEBI_/CL_/ENVO_ terms
  FQ_BASE excludes from /search by design. 15 of 56 fuzz queries returned
  zero docs and had been passing vacuously. The pool now carries FQ_BASE
  + FQ_NOT_DEPRECATED and is drawn from six seed-determined random
  windows rather than one head (one head is all FlyBase alleles: no
  anatomy class, individual, dataset or publication ever got fuzzed).
  78/78 now compare non-vacuously.
- check_recall.py measures and gates its "no extra Solr requests" claim
  instead of asserting it in prose.
- scripts/check_gates.sh rejects a valueless trailing --seed instead of
  silently treating it as "no seed".

Client (clients/vfbquery-client/)
- VfbError is exported. Catching it is the documented way to handle 503
  backpressure, and callers cannot write that retry loop against an
  exception living in a private module.
- Test for 503 -> VfbError at the transport layer (and that a 500 still
  raises requests.HTTPError, so the mapping is not "everything becomes
  VfbError"). _FakeResponse can now model a failure at all.

CI / tests
- tests/conftest.py and tests/test_search_xref_endpoints.py were
  untracked: test_resilience.py and test_shed_coalescing.py both import
  `run` from conftest, so a fresh clone and the search-gates workflow
  errored at collection.
- The workflow is committed at docs/ci/search-gates.yml, NOT
  .github/workflows/. GitHub rejects an entire push whose diff touches
  .github/workflows/ when the credential lacks `workflow` scope, and the
  one available here does. Parking it one directory over keeps the CI
  definition in the diff to review; `git mv docs/ci/search-gates.yml
  .github/workflows/` from a workflow-scoped checkout activates it. Until
  then scripts/check_gates.sh is the only thing running these gates.

Docs
- The parity section now distinguishes the live-loaded sorter from the
  vendored, pinned copy of refineResults (which will not notice an
  upstream change on its own), records the vacuous-fuzz finding, and
  attributes --seed to the parity gate rather than the recall gate.
- Client README and client.py document the always-on exact-label boost as
  the one deliberate departure from website order, and the once-per-site
  behaviour of Python's default warning filter.

All five gates green: unit, client, parity (78 queries), recall
(+1 recall, +1 rank0, 0 churn), live.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8ZAhRM7gh8arDx9SuSWcT
Answers the questions that need two queries and a comparison — "neurons in the
calyx AND the lateral horn", "MB dopaminergic BUT NOT octopaminergic" — which
attendees ask roughly two searches into a workshop, and which previously meant
downloading two tables and reconciling them by hand.

An expression, not a fixed pair of operands: `[calyx AND lh] NOT gaba`, up to 12
operands and 2000 characters, with OR/NOR loosest, XOR/XNOR next and
AND/NAND/NOT tightest, left-associative. Brackets are honoured so the caller can
say which comparison happens first, because `A OR B NOT C` means two different
things and guessing is not acceptable when the answer is a neuron list somebody
will act on.

Rows are matched on the identity column — the header whose declared type is
`selection_id` — rather than on position or label, so results from query types
with entirely different tables still compare. `NeuronInputsTo` is the one type
without that header and falls back to `id`; duplicate ids within an operand are
folded, which matters more than it sounds (DownstreamClassConnectivity on
FBbt_00049825 is 8,935 rows over 894 distinct terms).

The merge is lossless. Every operand's columns survive as `column__operand`,
alongside `found_in`, `found_in_count` and `from_query`, so an intersection
still carries the synapse counts from one side and the dataset from the other
instead of reducing to a list of ids.

Explanations are first-class, because the users who most need set algebra are
the ones who do not think in it: every response carries `as_read` (the
expression with the brackets the parser actually used), `plain_english`, and
per-step counts. An empty result carries `why_empty` naming the cause —
including the namespace mismatch that makes FBbt_ and VFB_ operands unable to
intersect by construction. `explain_only=1` returns the reading without running
anything.

Refusals are explicit rather than approximate: a truncated operand (the row cap
is real — DatasetImages/Nern2024 reports 60002 and returns 25000) makes the
combination unsound, so it comes back with warnings, and `require_complete=1`
turns that into a 409 instead. The universe is implicit and stated, which is why
NOR is always empty, NAND is XOR and XNOR is AND — documented rather than
silently surprising.

Engine and transport are separate on purpose. `vfbquery/combine.py` is
stdlib-only: parsing, precedence, the universe, the merge. Running the operands
belongs to ha_api, under the same cache keys and coalescer as /run_query, so a
combination of two cached queries costs no queries at all.

95 offline tests (65 engine, 30 endpoint), the endpoint set driving the real
handlers through a loopback server, plus eight client tests and ~20 biological
examples verified against live data.
The live gate polls /health with curl and no timeout, sixty times. Against a
server that holds the port and accepts connections but never answers, each probe
blocks until curl gives up on its own, and the gate stops for ten minutes with
nothing on stdout — which is exactly what a stale server from a previous run
looks like, because the EXIT trap does not fire for every way a run can end.

Three changes, each closing one step of that:

- every probe gets `--max-time 2`, which is generous for a handler that returns
  a dict literal;
- an already-serving port is a FAIL with the port named, not a bind error
  followed by sixty probes at somebody else's process — reporting another
  server's health as this checkout's is wrong in both directions, passing for
  code that is not running and failing for code that is fine;
- the trap covers INT and TERM as well as EXIT, so an interrupted run cleans up
  after itself instead of leaving the mess for the next one.
Six markdown files at the repo root plus five under docs/ describe this package
well, and they are unreachable unless you already know the repo. A Read the Docs
build makes them one URL, cross-linked, searchable and versioned with the
release — which matters most for the parts added on this branch, since /combine
is a language with precedence rules and nobody learns those from a README.

Shaped by two constraints:

README.md is generated — the examples workflow executes its python blocks and
rewrites its json blocks — and all six root documents are read on GitHub by
people who never open a docs site. So conf.py copies them into a gitignored
docs/_root/ at build time and rewrites the links between them, rather than
moving them or pulling them in with {include} (which would break the sibling
links they already have). The one hand edit to README.md is a prose line linking
to the site: both generators only rewrite fenced blocks in place, so prose
survives regeneration.

The builder installs vfbquery-client for real — requests and pandas, seconds —
so autodoc reads true signatures, and deliberately does not install vfbquery,
whose navis / psycopg / setuptools<58 weight is the thing the HTTP API exists to
remove. vfbquery.combine is stdlib-only and is reached through src/ with the
heavy siblings mocked. If autodoc ever needs a module that genuinely requires a
runtime dependency, that module is on the wrong side of the split.

Warnings are errors in both .readthedocs.yaml and the parked workflow, on
purpose: the site and the gate must not disagree about what green means. The one
suppression is named with its reason — several copied documents hold ```json
fences containing illustrative, unlexable JSON, and rewriting them to satisfy
Pygments would make them worse documents. Broken references and missing toctree
entries are not suppressed; they are what the gate is for.

New prose: a landing page, a getting-started walk-through, an HTTP endpoint
reference, a client guide, and autodoc pages for the client and for
vfbquery.combine.

The docs workflow is parked under docs/ci/ alongside search-gates.yml because
the credential this branch is pushed with has no workflow scope; docs/ci/README
gives the two git mv commands that activate both.

Tied to the release: setup.py and the client's pyproject gain Documentation URLs
pointing at /en/stable/ — the tag, not main, since somebody arriving from PyPI
has installed the release — and RELEASING.md explains that RTD needs nothing
added to the release procedure, and why a docs fix has to ship as a release to
reach those readers.
`.readthedocs.yaml` declares `formats: [pdf, htmlzip]`, so RTD runs a LaTeX
build on every commit whether or not anybody downloads the result. That build
did not fail gracefully — it died three separate ways, each of which would have
shown up as a red docs build for a reason nobody would connect to the docs.

1. A header-only markdown table in the generated `performance.md` crashed the
   LaTeX writer outright: Sphinx does `next(node.findall(nodes.tbody))` on every
   table, and a table with no body raises `StopIteration`. A run of the
   `performance-test` workflow that records no per-query timings emits exactly
   that. `_fill_empty_tables` gives such a table one em-dash row in the copy.

2. pdflatex treats a character it has no glyph for as a fatal error, and these
   documents are full of them — the ∪ ∩ ⊕ ↔ ≡ α β the /combine reference is
   written in, and the emoji the generated documents are full of. Switching to
   xelatex downgrades that to a warning, and DejaVu (guarded by
   `\IfFontExistsTF`, since it is a system font) covers the set notation
   properly rather than leaving a gap where the operator should be.

3. TeX collects a literal block into a single box so it can split it across
   pages, and a box taller than `\maxdimen` is a fatal "Dimension too large".
   `README.md` is generated — `update_readme.py` substitutes the real output of
   the examples — and `get_term_info` on a template returns every painted
   domain, so two of its blocks are ~1,100 lines of JSON, which clears the limit.

Neither source can be fixed at the source: the set notation is the subject
matter and the generated documents are rewritten by a workflow on every run. So
(2) and (3) are handled in `source-read`, applied only when the builder is
LaTeX: emoji are translated where they carry meaning (`performance.md`'s status
column is nothing but ✅ — 224 of the missing glyphs were that one column) and
dropped where they are decoration, and code blocks over 80 lines keep their head
and tail with a marked gap. Headings are exempt from the emoji pass because MyST
derives anchors from heading text and `VFB_QUERIES_REFERENCE.md` links to one of
those emoji-derived anchors by hand.

The HTML is untouched by all of this and remains the complete version.

    sphinx-build -b html  -W --keep-going   build succeeded
    sphinx-build -b latex -W --keep-going   build succeeded
    make (xelatex, 126pp)                   exit 0

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8ZAhRM7gh8arDx9SuSWcT
`query_connectivity` matched only the class it was handed. In FBbt the classes
people name are rarely the classes individuals are typed to: `Kenyon cell` has
zero direct instances — all ~16,000 hang off its 38 subclasses — so
`DA1 lPN -> Kenyon cell` returned an empty table, which reads as "these cells
are not connected" rather than "you named a parent". It now returns 1,504
connections (1,984 with `exclude_dbs=[]`), and the response carries a
`resolved` block saying what each label was taken to mean and how far the
expansion reached.

The query is driven from whichever side holds fewer individuals: expanding both
sides into variable-length walks does not complete, and the same answer costs
1.52s anchored on the smaller side against 7.54s on the larger. The dataset
filter moved from a trailing MATCH to an EXISTS predicate placed immediately
after the anchor's WITH DISTINCT — as a MATCH it both multiplied rows and cost
70s on a query that now takes 0.4s. Both anchor directions were checked to
return identical counts.

DEFAULT_EXCLUDE_DBS now carries the reasoning for `hb` and `fafb` — double
counting, not data quality — in the constant, in docs/http-api.md, in the
client README and in the docstring, and both hardcoded literals now read from
it. The client gained `exclude_dbs` and `group_by_class`, and either type may
be omitted for the one-sided question.

`/get_hierarchy` joins ALLOWED_PATHS and the docs: it is an ordinary query and
was private only because nobody had asked. `/get_hierarchy_html` stays
in-cluster — it is the pre-rendered markup the geppetto ROI browser consumes,
and publishing one consumer's HTML as API surface buys nothing the JSON does
not already give.

The client's read timeout goes to 180s, and the live gate's connectivity case
drops from Tm1 -> T3 (4,915 individuals against 5,430, 30-60s cold) to the
giant fiber against its PSI target (8 against 6, about a second). That gate
exists to prove `weight` reaches the server, not to time Neo4j; subclass
expansion is covered against the live database by the server-side suite.

Version 1.23.0 rather than 1.22.34: the same call now returns different rows,
and cache entries are namespaced by major.minor so an output change leaves the
old ones behind instead of serving them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants