diff --git a/app/api/chat/route.js b/app/api/chat/route.js index 402beb1..d18f901 100644 --- a/app/api/chat/route.js +++ b/app/api/chat/route.js @@ -52,7 +52,8 @@ import { annotateFailedRunQuery, createForceRefreshBudget, forceRefreshKey, - isFailedRunQueryPayload + isFailedRunQueryPayload, + isSuspiciousZeroRunQuery } from '../../../lib/runQueryRetry.mjs' import { parseThumbnailEntity, @@ -1261,7 +1262,14 @@ function getForceRefreshBudget(context) { async function callMcpToolTextWithForceRefresh(client, name, args, { budget } = {}) { const text = mcpResultToText(await callMcpToolWithRetry(client, name, args)) - if (!FORCE_REFRESH_RETRY_TOOLS.has(name) || !isFailedRunQueryPayload(text)) return text + if (!FORCE_REFRESH_RETRY_TOOLS.has(name)) return text + const failed = isFailedRunQueryPayload(text) + // A class-connectivity count 0 is retried like a -1 (#66): the edge cache + // kept the zeros VFBquery used to emit for a failed aggregation, and one is + // indistinguishable from an empty set until it has been recomputed once. + // See SUSPICIOUS_ZERO_QUERY_TYPES for the scope; a second zero is believed. + const suspiciousZero = !failed && isSuspiciousZeroRunQuery(name, args, text) + if (!failed && !suspiciousZero) return text // `budget` was passed by exactly one of the 27 call sites — the generic MCP // routing path. Every macro tool left it empty and fell back to a fresh @@ -1272,13 +1280,24 @@ async function callMcpToolTextWithForceRefresh(client, name, args, { budget } = // that is seven X-Force-Refresh recomputes against the shared upstream instead // of two. The request's allowance now rides on the client. const allowance = budget || client?.[FORCE_REFRESH_BUDGET] || createForceRefreshBudget(1) + // annotateFailedRunQuery leaves a count-0 payload untouched, so the + // suspicious-zero path returns the original result when the budget is spent. if (!allowance.tryConsume(forceRefreshKey(name, args))) return annotateFailedRunQuery(text) - console.error(`[VFBchat] ${name} returned count -1 — retrying once with force_refresh | args=${safeToolArgs(args)}`) + const why = failed ? 'count -1' : 'count 0 for class connectivity' + console.error(`[VFBchat] ${name} returned ${why} — retrying once with force_refresh | args=${safeToolArgs(args)}`) + const startedAt = Date.now() try { const retryText = mcpResultToText( await callMcpToolWithRetry(client, name, { ...args, force_refresh: true }) ) + if (suspiciousZero) { + // Recorded either way: a zero that became a result is a cache the edge + // is still serving, and a zero that stayed zero is a class that really + // has no connectome-annotated instances. Both are worth counting. + const after = Number(parseMaybeJsonCount(retryText)) + console.error(`[VFBchat] CLASS CONNECTIVITY ZERO RETRY | query_type=${args?.query_type} | id=${safeToolArgs({ id: args?.id })} | count_after=${Number.isFinite(after) ? after : 'n/a'} | ms=${Date.now() - startedAt}`) + } if (!isFailedRunQueryPayload(retryText)) return retryText return annotateFailedRunQuery(retryText) } catch (error) { @@ -1287,6 +1306,15 @@ async function callMcpToolTextWithForceRefresh(client, name, args, { budget } = } } +function parseMaybeJsonCount(text) { + try { + const parsed = typeof text === 'string' ? JSON.parse(text) : text + return parsed && typeof parsed === 'object' ? parsed.count : undefined + } catch { + return undefined + } +} + // The version sent here is what the MCP server records against every call this // client makes, so it comes from the one source in lib/appVersion.mjs. A // hard-coded copy goes stale at the first release nobody remembers to edit it @@ -11348,6 +11376,31 @@ async function localiseFollowOns(followOns, language, { apiBaseUrl, apiKey, apiM return chips.map((c, i) => (labels[i] && labels[i] !== c.label ? { ...c, label: labels[i] } : c)) } +/** + * One chip per reading the resolver could not choose between (#66 follow-up): + * the user's own question with the ambiguous name swapped for the reading's + * VFB label, so clicking it re-asks the question about exactly that term. When + * the name is not literally in the question (the planner paraphrased it) the + * label is prefixed instead, which the next turn's planner reads as the term. + * Exported for the unit test; no model, no network. + */ +export function clarifyReadingChips(question = '', options = []) { + const out = [] + const seen = new Set() + for (const o of Array.isArray(options) ? options : []) { + const label = String(o?.label || '').trim() + const name = String(o?.name || '').trim() + if (!label || seen.has(label.toLowerCase())) continue + seen.add(label.toLowerCase()) + const q = String(question || '').trim() + const re = name ? new RegExp(`(?= 6) break + } + return out +} + /** * The follow-on chips of the previous turn, as the client sent them back in * its history, for the turn that re-renders that answer in another language. @@ -11560,13 +11613,19 @@ async function runRoleHarnessForRequest({ priorMessages, lastAssistantText = '', // answer. The Finglish question that got "Do you want to know how many // split-GAL4 driver lines…?" back in English is the case. const clarifyRendered = await renderInLanguage({ text: clarifyEnglish, language, kind: 'clarification', sendEvent, ...languageDeps }) + // When the resolver asked which of several exact readings was meant, the + // readings are offered as chips: each re-asks THIS question with the + // ambiguous name replaced by the term's own VFB label, which the next + // turn resolves exactly. Labels are localised like any other chip. + const readingChips = clarifyReadingChips(userMessage, live.ledger?.clarifyOptions) + const clarifyFollowOns = readingChips.length ? await localiseFollowOns(readingChips, language, languageDeps) : [] return { ok: true, responseText: clarifyRendered.text, images: [], graphs: [], tables: [], - followOns: [], + followOns: clarifyFollowOns, sources: [], // Even a clarifying turn carries the context forward. A question the // harness could not answer without more information is exactly the diff --git a/lib/classPartners.mjs b/lib/classPartners.mjs index 5260be4..9c8ff2a 100644 --- a/lib/classPartners.mjs +++ b/lib/classPartners.mjs @@ -449,7 +449,14 @@ export function summariseClassPartners(parsed, { label = '', partnerFilter = '', partners: ranked.partners, aggregates: ranked.aggregates, self: ranked.self, - rows: ranked.partners.map(r => ({ name: r.label, id: r.id })) + // Every named class, not only the ranked partners: the roll-up classes and + // the self-row are named in the claim and in the table too, and `rows` is + // what the caller registers for linking. Registering partners alone left + // "adult neuron, adult CNS neuron, adult interneuron" as the only names in + // a DNp32 answer without a link (Korean turn, 10 Sep 2026). + rows: [...ranked.partners, ...ranked.aggregates, ...ranked.self] + .filter(r => r?.id) + .map(r => ({ name: r.label, id: r.id })) } } diff --git a/lib/orchestrator.mjs b/lib/orchestrator.mjs index bfc7865..11aa3d9 100644 --- a/lib/orchestrator.mjs +++ b/lib/orchestrator.mjs @@ -403,6 +403,10 @@ export async function runHarness(question, deps) { const terms = speculative.length ? [...action.terms, ...speculative] : action.terms emit(deps, `Resolving ${terms.length} term${terms.length === 1 ? '' : 's'} in VFB`, 'mcp') await resolveTerms(ledger, terms, deps, models, log, new Set(speculative)) + // A name the resolver could not read one way (askWhichReading) turns + // the turn into a clarifying question: nothing below should plan, inject + // or run a step against a term the user has not yet chosen. + if (ledger.underspecified && ledger.clarifyingQuestion) continue displaceBackReferences(ledger, question, questionSymbols, log) // Deterministic graph routing: a connectivity/graph question about a single // resolved NEURON TYPE always runs the connectivity tool, so a graph appears @@ -939,6 +943,12 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new // nameVariants) and the loop stops at the first that is accepted, so the // usual single-plural case still costs exactly one extra search. let resolvedId = directId || pickBestTermId(search, name) + // The wording the chosen document was matched AGAINST — the name, or the + // variant, translation or symbol that replaced it below. The tie check at + // the end of the ladder asks which other documents match this wording + // exactly, and asking that of the original name after a variant matched + // would find nothing. + let matchedAs = name // Why the retry happened, in the vocabulary the trace already uses: the // three states are distinguishable and a resolution that came from a variant // is only explainable afterwards if the trace says which state it was in. @@ -977,6 +987,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new if (accepted) { search = retry resolvedId = accepted + matchedAs = exact ? variant : name log({ resolve_retry: name, as: variant, reason: retryReason }) break } @@ -1010,6 +1021,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new if (hit) { search = probed resolvedId = hit.id + matchedAs = hit.as || name log({ resolve_spelling: name, as: hit.as, id: hit.id, edits: hit.dist, probe }) break } @@ -1039,6 +1051,7 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new if (hit) { search = translated resolvedId = hit + matchedAs = english log({ resolve_translation: name, as: english, id: hit, language: ledger.language }) } else if (searchIsEmpty(search)) { // Candidates to show, at least: the English wording found @@ -1091,8 +1104,25 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new // knows about EPG neurons (#59). A plural or generic name that landed // on an INDIVIDUAL without matching its label exactly is a name for a // class: search the symbol alone among classes, and take an exact match. + // + // The same lift applies to a BARE symbol that landed on an individual by + // GUESS (#66). "what cell types are downstream of KCg?" resolved "KCg" + // to KCg-d_R (FlyEM-HB:1003837184), one hemibrain cell, and the answer + // listed that cell's 221 partners as the cell types downstream of KCg. + // VFB's index ranks the five hundred KCg-* individuals above the class + // whose symbol IS "KCg" (gamma Kenyon cell, FBbt_00100247), so the class + // never reached the thirty rows the ladder saw, and the token-superset + // rule took the first individual carrying the token. Nothing there was + // an exact match — the individual's label is "KCg-d_R (…)", not "KCg". + // A name that matched NO document exactly, and whose exact match among + // classes exists, names that class: exact beats guess, which is the + // ladder's own first rule, applied across the search the ranking hid. + // A name that IS an individual's label or synonym ("EPG neuron" in #59 + // matched a synonym exactly) is only lifted when its shape says type. const chosenNow = validSearchDocs(search).find(d => sfOf(d) === resolvedId) - if (chosenNow && docIsIndividual(chosenNow) && namesAType(name) && norm(docLabel(chosenNow)) !== norm(name)) { + const liftable = chosenNow && docIsIndividual(chosenNow) && norm(docLabel(chosenNow)) !== norm(name) && + (namesAType(name) || !exactTermMatchId(search, name)) + if (liftable) { const symbol = stripEntityNoun(name) if (symbol && !budget.expired()) { const classRaw = await raceDeadline( @@ -1105,9 +1135,44 @@ async function resolveTerms(ledger, names, deps, models, log, speculative = new log({ resolve_lift_to_class: name, from: resolvedId, to: classId, as: symbol }) resolvedId = classId search = classSearch + matchedAs = symbol } } } + // TIED EXACT MATCHES. "KCg" is the symbol of BOTH "adult gamma Kenyon + // cell" (FBbt_00049828) and "gamma Kenyon cell" (FBbt_00100247), and + // exactTermMatchId hands back whichever VFB ranked first — a coin the + // user never saw tossed. When several documents match the wording + // exactly on the same rung, the question decides if it can (a stage + // word: "adult", "larval"); failing that the model is asked whether the + // question itself settles it; failing that the USER is asked, with the + // readings as chips, rather than answered about a term they may not + // have meant. A single exact match, or a clear winner between rungs + // (a label beats a synonym), is not a tie and none of this runs. + // A speculative name is one WE wrote, so the user cannot be asked which + // of its readings they meant; VFB's first stays. And only CLASSES tie: + // five hemibrain cells all labelled "KCg-d_R" are not readings a user + // can choose between by name — that case is answered about one and the + // others are disclosed (sameKindAlternatives, below). + const tied = speculative.has(name) ? [] : exactTermMatchDocs(search, matchedAs).filter(d => !docIsIndividual(d)) + if (tied.length > 1 && tied.some(d => sfOf(d) === resolvedId)) { + let pick = decideTiedReadingFromQuestion(ledger.question, tied) + let via = pick ? 'question' : '' + if (!pick && typeof deps.callStructured === 'function' && !budget.expired()) { + pick = await decideTiedReadingWithModel(ledger.question, name, tied, deps, models) + via = pick ? 'model' : '' + } + if (pick) { + if (sfOf(pick) !== resolvedId) resolvedId = sfOf(pick) + log({ resolve_disambiguated: name, as: matchedAs, to: resolvedId, via, among: tied.map(sfOf) }) + } else { + const options = tied.map(d => ({ id: sfOf(d), label: docLabel(d) })) + askWhichReading(ledger, name, options) + addTerm(ledger, name, { id: null, attempted: true, candidates: options.map(o => o.label), ambiguous: true, speculative: speculative.has(name) }) + log({ resolve_ambiguous: name, as: matchedAs, candidates: options.map(o => o.id) }) + return + } + } // A bare symbol that several same-kind terms carry ("DA1": DA1 PN, DA1 // lPN, DA1 vPN, MN-DA1) has been READ, not matched. The reading is used — // an answer beats a clarifying question here — but the answer must say @@ -4860,6 +4925,99 @@ export function exactTermMatchId(search, queryName = '') { return singExact ? sfOf(singExact) : null } +/** + * EVERY document that matches `queryName` exactly on the strongest rung any + * document reaches — the same three rungs as exactTermMatchId, and the same + * precedence, so the first of these is always what exactTermMatchId returns. + * Two documents here are a tie the ladder cannot break on its own: "KCg" is + * the symbol of both "adult gamma Kenyon cell" and "gamma Kenyon cell", and + * nothing in the wording prefers one. One document, or none, is not a tie. + * De-duplicated by id: VFB returns a row per matching synonym when asked to. + */ +export function exactTermMatchDocs(search, queryName = '') { + const q = norm(queryName) + if (!q) return [] + const valid = validSearchDocs(search) + if (!valid.length) return [] + const uniq = (docs) => { + const seen = new Set() + return docs.filter(d => { const id = sfOf(d); if (!id || seen.has(id)) return false; seen.add(id); return true }) + } + const byLabel = uniq(valid.filter(d => norm(docLabel(d)) === q)) + if (byLabel.length) return byLabel + const bySyn = uniq(valid.filter(d => docSynonyms(d).includes(q))) + if (bySyn.length) return bySyn + const qTokFull = toks(q) + if (!qTokFull.length) return [] + return uniq(valid.filter(d => sameTokenSet(toks(docLabel(d)), qTokFull))) +} + +const STAGE_WORDS = ['adult', 'larval', 'larva', 'embryonic', 'embryo', 'pupal', 'pupa'] +const stageIn = (text) => STAGE_WORDS.filter(w => new RegExp(`\\b${w}\\b`, 'i').test(String(text || ''))) +const sameStage = (a, b) => a.replace(/^(larva|embryo|pupa)$/, m => ({ larva: 'larval', embryo: 'embryonic', pupa: 'pupal' })[m]) === + b.replace(/^(larva|embryo|pupa)$/, m => ({ larva: 'larval', embryo: 'embryonic', pupa: 'pupal' })[m]) + +/** + * The one tied reading the QUESTION picks out, or null. Deterministic and + * narrow: a stage word in the question ("adult", "larval") selects the one + * candidate whose label carries that stage. A question that names no stage + * does not prefer the stage-agnostic class — that is exactly the choice the + * user is asked about. + */ +export function decideTiedReadingFromQuestion(question = '', docs = []) { + const asked = stageIn(question) + if (!asked.length) return null + const hits = docs.filter(d => stageIn(docLabel(d)).some(s => asked.some(a => sameStage(a.toLowerCase(), s.toLowerCase())))) + return hits.length === 1 ? hits[0] : null +} + +const TIED_READING_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['choice'], + properties: { choice: { type: 'integer' } } +} + +/** + * Ask the model whether the question itself settles which tied reading was + * meant. It may only choose on evidence in the question — a dataset, a stage, + * a qualifier, an earlier mention — and returns null (ask the user) otherwise. + * One structured call on the extract profile; any failure is "unsure". + */ +export async function decideTiedReadingWithModel(question, name, docs, deps, models = {}) { + const candidates = docs.map((d, i) => ({ index: i, label: docLabel(d) })) + const messages = [ + { + role: 'system', + content: 'A name in a user\'s question about Drosophila neuroanatomy matches more than one Virtual Fly Brain term exactly. Decide which term the user meant ONLY if the question itself decides it — a life stage, a dataset, a qualifier, or wording that fits exactly one candidate. If the question does not decide, answer -1 so the user can be asked. Never guess from which candidate seems more common. Reply with JSON {"choice": }.' + }, + { role: 'user', content: JSON.stringify({ question: String(question || ''), name: String(name), candidates }) } + ] + try { + const r = await deps.callStructured({ messages, schema: TIED_READING_SCHEMA, schemaName: 'tied_term_reading', model: models.extract }) + const i = r?.ok ? Number(r.value?.choice) : -1 + return Number.isInteger(i) && i >= 0 && i < docs.length ? docs[i] : null + } catch { + return null + } +} + +/** + * Turn an unbroken tie into the turn's clarifying question, with the readings + * carried as options for the follow-on chips. Written in English like the + * planner's own clarifying question, so it is rendered in the user's language + * by the same path. Labels only — an id in the text would be stripped as a + * leak, and is not what a reader chooses by. + */ +export function askWhichReading(ledger, name, options = []) { + const labels = options.map(o => o.label).filter(Boolean) + if (labels.length < 2) return + const list = labels.length === 2 ? `${labels[0]} or ${labels[1]}` : `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}` + ledger.underspecified = true + ledger.clarifyingQuestion = `"${name}" matches more than one VFB term: ${list}. Which did you mean?` + ledger.clarifyOptions = [...(ledger.clarifyOptions || []), ...options.map(o => ({ name, id: o.id, label: o.label }))] +} + export function pickBestTermId(search, queryName = '') { const valid = validSearchDocs(search) if (!valid.length) return null diff --git a/lib/runQueryRetry.mjs b/lib/runQueryRetry.mjs index c576752..ffa4f85 100644 --- a/lib/runQueryRetry.mjs +++ b/lib/runQueryRetry.mjs @@ -57,6 +57,44 @@ export function isFailedRunQueryPayload(text) { return Number.isFinite(count) && count < 0 } +/** + * Query types whose empty result is worth one forced recompute (#66). + * + * Class connectivity is an aggregate over every instance of the class, built + * live by VFBquery. Before v1.22.51 (4 Sep 2026) any backend hiccup during + * that aggregation collapsed to an empty list, which was reported as count 0 + * "exact" and stored by every cache in front of it — the Solr result cache + * and the nginx edge, under EVERY URL variant that had been asked. VFBquery no + * longer produces those zeros, but the edge still serves the ones it kept: + * "what cell types are downstream of KCg?" got count 0 for gamma Kenyon cell + * (FBbt_00100247) on the MCP's `offset=0&limit=…` slots while the plain URL + * returned 3,886 rows, and the chat answered "VFB does not currently hold + * data on downstream connectivity" about the most-studied cell type in the + * fly brain. + * + * So for these two query types alone, count 0 is treated the way count -1 is: + * retried once past the cache, on the same per-request budget. A genuine zero + * — a class with no connectome-annotated instances — recomputes quickly, + * because the aggregate is over nothing, and comes back 0 again; that second + * zero is believed. Everything else keeps the rule above: 0 is 0. + */ +export const SUSPICIOUS_ZERO_QUERY_TYPES = new Set(['DownstreamClassConnectivity', 'UpstreamClassConnectivity']) + +/** + * An empty class-connectivity result that may be a stale cached failure rather + * than an empty set. Only for run_query, only for the query types above, and + * only when the payload is a bona fide "count 0, no rows" — a payload that + * carries rows, an error, or a negative count is somebody else's case. + */ +export function isSuspiciousZeroRunQuery(toolName, args = {}, text) { + if (toolName !== 'run_query') return false + if (!SUSPICIOUS_ZERO_QUERY_TYPES.has(String(args?.query_type || ''))) return false + const parsed = parsePayload(text) + if (!parsed || parsed.error) return false + if (Number(parsed.count) !== 0) return false + return !Array.isArray(parsed.rows) || parsed.rows.length === 0 +} + /** * Add the failure explanation to a payload, leaving any existing note in place. * Returns the text unchanged if it is not a recognisable failed payload, so this diff --git a/tests/unit/clarifyReadingChips.test.mjs b/tests/unit/clarifyReadingChips.test.mjs new file mode 100644 index 0000000..4913105 --- /dev/null +++ b/tests/unit/clarifyReadingChips.test.mjs @@ -0,0 +1,36 @@ +// When the resolver asks which of several exact readings a name meant, the +// readings are offered as chips that re-ask the question about exactly that +// term. No model, no network. +// +// Run: node --test tests/unit/clarifyReadingChips.test.mjs + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { clarifyReadingChips } from '../../app/api/chat/route.js' + +const OPTIONS = [ + { name: 'KCg', id: 'FBbt_00049828', label: 'adult gamma Kenyon cell' }, + { name: 'KCg', id: 'FBbt_00100247', label: 'gamma Kenyon cell' } +] + +test('each reading becomes a chip that re-asks the question with the label in place of the name', () => { + const chips = clarifyReadingChips('what cell types are downstream of KCg?', OPTIONS) + assert.deepEqual(chips.map(c => c.kind), ['ask', 'ask']) + assert.deepEqual(chips.map(c => c.label), ['adult gamma Kenyon cell', 'gamma Kenyon cell']) + assert.deepEqual(chips.map(c => c.query), [ + 'what cell types are downstream of adult gamma Kenyon cell?', + 'what cell types are downstream of gamma Kenyon cell?' + ]) +}) + +test('the name is replaced as a whole word only; a paraphrased question gets the label appended', () => { + const chips = clarifyReadingChips('what is downstream of KCg-d?', OPTIONS) + assert.equal(chips[0].query, 'what is downstream of KCg-d? (I mean adult gamma Kenyon cell)') + assert.equal(clarifyReadingChips('', OPTIONS)[1].query, 'gamma Kenyon cell') +}) + +test('duplicates and empties are dropped; nothing without options', () => { + assert.equal(clarifyReadingChips('q', [...OPTIONS, OPTIONS[0], { label: '' }]).length, 2) + assert.deepEqual(clarifyReadingChips('q', undefined), []) +}) diff --git a/tests/unit/classLift.test.mjs b/tests/unit/classLift.test.mjs index fa2e240..cbaedbd 100644 --- a/tests/unit/classLift.test.mjs +++ b/tests/unit/classLift.test.mjs @@ -76,6 +76,59 @@ test('"EPG neurons" is lifted from the instance the singular found to the class assert.deepEqual(deps.calls[2].filter, ['class']) }) +// Issue #66: "what cell types are downstream of KCg?" resolved the bare symbol +// to KCg-d_R (FlyEM-HB:1003837184). VFB's index ranks hundreds of KCg-* +// individuals above the class whose symbol is "KCg", so the class never +// reached the rows the ladder saw and the token-superset guess took the first +// individual. No document matched "KCg" exactly; the class does, so it wins. +const KCG_INSTANCES = ['1003837184', '1004514584', '1004514714'].map((acc, i) => ({ + short_form: `VFB_jrchjw0${i}`, + label: `KCg-d_R (KCg-d_R (FlyEM-HB:${acc}))`, + original_label: `KCg-d_R (FlyEM-HB:${acc})`, + facets_annotation: ['Entity', 'Individual', 'Neuron', 'Adult', 'has_neuron_connectivity'] +})) +const KCG_CLASS = { short_form: 'FBbt_00100247', label: 'KCg (gamma Kenyon cell)', original_label: 'gamma Kenyon cell', synonym: ['KCg', 'gamma KC'], facets_annotation: ['Entity', 'Class', 'Neuron'] } + +test('a bare symbol that landed on an instance by guess is lifted to the class the symbol names exactly (#66)', async () => { + const deps = makeDeps('what cell types are downstream of KCg?', 'KCg') + deps.runTool = (orig => async (name, args) => { + if (name === 'vfb_search_terms') { + deps.calls.push({ q: args.query, filter: args.filter_types || null }) + if ((args.filter_types || []).includes('class')) return { results: args.query === 'KCg' ? [KCG_CLASS] : [] } + return { results: KCG_INSTANCES } + } + if (name === 'vfb_get_term_info') { + return args.id === 'FBbt_00100247' + ? { Id: args.id, Name: 'gamma Kenyon cell', IsClass: true, SuperTypes: ['Class', 'Neuron'], Publications: [], Queries: [] } + : { Id: args.id, Name: 'KCg-d_R (FlyEM-HB:1003837184)', IsIndividual: true, SuperTypes: ['Individual', 'Neuron'], Publications: [], Queries: [] } + } + return orig(name, args) + })(deps.runTool) + const r = await runHarness('what cell types are downstream of KCg?', deps) + assert.equal(r.ledger.terms.KCg.id, 'FBbt_00100247') + assert.ok(r.trace.some(e => e.resolve_lift_to_class === 'KCg' && e.from === 'VFB_jrchjw00' && e.to === 'FBbt_00100247'), 'lift logged') + const classCall = deps.calls.find(c => c.filter && c.filter.includes('class')) + assert.ok(classCall && classCall.q === 'KCg', 'the symbol was searched among classes') +}) + +test('a bare name that matches an instance exactly (label sans accession) is not lifted', async () => { + const deps = makeDeps('what is downstream of KCg-d_R?', 'KCg-d_R') + deps.runTool = (orig => async (name, args) => { + if (name === 'vfb_search_terms') { + deps.calls.push({ q: args.query, filter: args.filter_types || null }) + // A class search would find a sibling symbol; it must never be made. + if ((args.filter_types || []).includes('class')) return { results: [{ short_form: 'FBbt_00110932', label: 'KCg-d (gamma dorsal Kenyon cell)', original_label: 'gamma dorsal Kenyon cell', synonym: ['KCg-d'], facets_annotation: ['Entity', 'Class', 'Neuron'] }] } + return { results: KCG_INSTANCES } + } + if (name === 'vfb_get_term_info') return { Id: args.id, Name: 'KCg-d_R (FlyEM-HB:1003837184)', IsIndividual: true, SuperTypes: ['Individual', 'Neuron'], Publications: [], Queries: [] } + return orig(name, args) + })(deps.runTool) + const r = await runHarness('what is downstream of KCg-d_R?', deps) + assert.equal(r.ledger.terms['KCg-d_R'].id, 'VFB_jrchjw00') + assert.ok(!r.trace.some(e => e.resolve_lift_to_class), 'no lift') + assert.ok(!deps.calls.some(c => c.filter && c.filter.includes('class')), 'no class search') +}) + test('a name that IS the instance label is left on the instance', async () => { const deps = makeDeps('what is EPG-5L#3 (FAFB:4087066) connected to?', 'EPG-5L#3 (FAFB:4087066)') deps.runTool = (orig => async (name, args) => { @@ -86,3 +139,91 @@ test('a name that IS the instance label is left on the instance', async () => { assert.equal(r.ledger.terms['EPG-5L#3 (FAFB:4087066)'].id, 'VFB_001012bq') assert.ok(!r.trace.some(e => e.resolve_lift_to_class), 'no lift') }) + +// --- tied exact readings ------------------------------------------------------ +// +// "KCg" is the symbol of BOTH "adult gamma Kenyon cell" and "gamma Kenyon cell". +// The question decides when it names a stage; the model is asked whether the +// question decides otherwise; and when neither can, the USER is asked, with the +// readings as options, rather than answered about VFB's first-ranked one. + +import { exactTermMatchDocs, decideTiedReadingFromQuestion, askWhichReading } from '../../lib/orchestrator.mjs' + +const KCG_ADULT = { short_form: 'FBbt_00049828', label: 'KCg (adult gamma Kenyon cell)', original_label: 'adult gamma Kenyon cell', synonym: ['KCg'], facets_annotation: ['Entity', 'Class', 'Neuron', 'Adult'] } +const KCG_ANY = { short_form: 'FBbt_00100247', label: 'KCg (gamma Kenyon cell)', original_label: 'gamma Kenyon cell', synonym: ['KCg', 'gamma KC'], facets_annotation: ['Entity', 'Class', 'Neuron'] } +const KCG_D = { short_form: 'FBbt_00110932', label: 'KCg-d (gamma dorsal Kenyon cell)', original_label: 'gamma dorsal Kenyon cell', synonym: ['KCg-d'], facets_annotation: ['Entity', 'Class', 'Neuron'] } + +test('exactTermMatchDocs returns every document on the strongest rung, and nothing weaker', () => { + const search = { results: [KCG_ADULT, KCG_ANY, KCG_D] } + assert.deepEqual(exactTermMatchDocs(search, 'KCg').map(d => d.short_form), ['FBbt_00049828', 'FBbt_00100247']) + // A label match outranks the synonym matches: no tie. + assert.deepEqual(exactTermMatchDocs({ results: [KCG_ANY, { ...KCG_D, original_label: 'KCg', label: 'KCg (FBbt_x)', short_form: 'FBbt_x' }] }, 'KCg').map(d => d.short_form), ['FBbt_x']) + assert.deepEqual(exactTermMatchDocs(search, 'KCg-d').map(d => d.short_form), ['FBbt_00110932']) + assert.deepEqual(exactTermMatchDocs(search, 'nothing'), []) +}) + +test('a stage word in the question decides a tie; no stage word decides nothing', () => { + assert.equal(decideTiedReadingFromQuestion('what is downstream of adult KCg?', [KCG_ADULT, KCG_ANY])?.short_form, 'FBbt_00049828') + assert.equal(decideTiedReadingFromQuestion('larval KCg outputs', [KCG_ADULT, KCG_ANY]), null) + assert.equal(decideTiedReadingFromQuestion('what cell types are downstream of KCg?', [KCG_ADULT, KCG_ANY]), null) +}) + +test('askWhichReading writes the clarifying question and the options', () => { + const ledger = {} + askWhichReading(ledger, 'KCg', [{ id: 'FBbt_00049828', label: 'adult gamma Kenyon cell' }, { id: 'FBbt_00100247', label: 'gamma Kenyon cell' }]) + assert.equal(ledger.underspecified, true) + assert.equal(ledger.clarifyingQuestion, '"KCg" matches more than one VFB term: adult gamma Kenyon cell or gamma Kenyon cell. Which did you mean?') + assert.deepEqual(ledger.clarifyOptions.map(o => o.id), ['FBbt_00049828', 'FBbt_00100247']) + assert.ok(!/FBbt_/.test(ledger.clarifyingQuestion), 'no ids in the question') +}) + +function tiedDeps(question, { decide = -1 } = {}) { + const deps = makeDeps(question, 'KCg') + const orig = deps.callStructured + deps.callStructured = async (req) => { + if (req.schemaName === 'tied_term_reading') { deps.calls.push({ tie: JSON.parse(req.messages[1].content) }); return { ok: true, value: { choice: decide } } } + return orig(req) + } + deps.runTool = async (name, args) => { + if (name === 'vfb_search_terms') { + deps.calls.push({ q: args.query, filter: args.filter_types || null }) + if ((args.filter_types || []).includes('class')) return { results: [KCG_ADULT, KCG_ANY, KCG_D] } + return { results: KCG_INSTANCES } + } + if (name === 'vfb_get_term_info') return { Id: args.id, Name: args.id === 'FBbt_00049828' ? 'adult gamma Kenyon cell' : 'gamma Kenyon cell', IsClass: true, SuperTypes: ['Class', 'Neuron'], Publications: [], Queries: [] } + return { ok: true } + } + return deps +} + +test('a tie the question and the model cannot break asks the user, with the readings as options', async () => { + const deps = tiedDeps('what cell types are downstream of KCg?') + const r = await runHarness('what cell types are downstream of KCg?', deps) + assert.equal(r.clarify, true) + assert.equal(r.answer, '"KCg" matches more than one VFB term: adult gamma Kenyon cell or gamma Kenyon cell. Which did you mean?') + assert.deepEqual(r.ledger.clarifyOptions.map(o => o.id), ['FBbt_00049828', 'FBbt_00100247']) + assert.equal(r.ledger.terms.KCg.id, null) + assert.equal(r.ledger.terms.KCg.ambiguous, true) + const tie = deps.calls.find(c => c.tie) + assert.ok(tie, 'the model was consulted') + assert.deepEqual(tie.tie.candidates.map(c => c.label), ['adult gamma Kenyon cell', 'gamma Kenyon cell']) + assert.ok(r.trace.some(e => e.resolve_ambiguous === 'KCg'), 'ambiguity logged') + assert.ok(!r.trace.some(e => e.run_step), 'no step ran against an unchosen term') +}) + +test('a tie the model can break from the question is not asked about', async () => { + const deps = tiedDeps('what cell types are downstream of KCg in the hemibrain?', { decide: 0 }) + const r = await runHarness('what cell types are downstream of KCg in the hemibrain?', deps) + assert.ok(!r.clarify) + assert.equal(r.ledger.terms.KCg.id, 'FBbt_00049828') + assert.ok(r.trace.some(e => e.resolve_disambiguated === 'KCg' && e.via === 'model'), 'model decision logged') +}) + +test('a stage word settles the tie before the model is consulted', async () => { + const deps = tiedDeps('what cell types are downstream of adult KCg?') + const r = await runHarness('what cell types are downstream of adult KCg?', deps) + assert.ok(!r.clarify) + assert.equal(r.ledger.terms.KCg.id, 'FBbt_00049828') + assert.ok(r.trace.some(e => e.resolve_disambiguated === 'KCg' && e.via === 'question')) + assert.ok(!deps.calls.some(c => c.tie), 'model not consulted') +}) diff --git a/tests/unit/classPartners.test.mjs b/tests/unit/classPartners.test.mjs index 4c76d73..0956b8d 100644 --- a/tests/unit/classPartners.test.mjs +++ b/tests/unit/classPartners.test.mjs @@ -333,6 +333,19 @@ test('the claim answers the question that was asked', () => { assert.equal(s.rows[0].id, 'FBbt_90000029') }) +test('every class the claim names is registrable: roll-ups and the self-row too', () => { + // The caller registers `rows` for linking. The roll-up classes are named in + // the claim and shown in the table, so an answer that mentions them must be + // able to link them — a DNp32 answer left "adult neuron, adult CNS neuron, + // adult interneuron" as its only unlinked names. + const s = summariseClassPartners(downstreamPayload(), { label: 'Kenyon cell' }) + const names = s.rows.map(r => r.name) + for (const r of s.aggregates) assert.ok(names.includes(r.label), `roll-up ${r.label} is in rows`) + for (const r of s.self) assert.ok(names.includes(r.label), `self ${r.label} is in rows`) + assert.ok(s.rows.every(r => r.id), 'every row carries an id') + assert.equal(s.rows.length, s.partners.length + s.aggregates.length + s.self.length) +}) + test('the collapsed names are surfaced in the claim, not swallowed', () => { const c = summariseClassPartners(downstreamPayload(), { label: 'Kenyon cell' }).claim assert.ok(/VFB lists the same connections under/.test(c), c) diff --git a/tests/unit/runQueryRetry.test.mjs b/tests/unit/runQueryRetry.test.mjs index abd2ea6..413d388 100644 --- a/tests/unit/runQueryRetry.test.mjs +++ b/tests/unit/runQueryRetry.test.mjs @@ -110,3 +110,25 @@ test('the default allowance is small and positive', () => { assert.ok(DEFAULT_FORCE_REFRESH_BUDGET > 0 && DEFAULT_FORCE_REFRESH_BUDGET <= 3) assert.equal(createForceRefreshBudget().remaining, DEFAULT_FORCE_REFRESH_BUDGET) }) + +// ------------------------------------------- suspicious class-connectivity zero + +import { isSuspiciousZeroRunQuery, SUSPICIOUS_ZERO_QUERY_TYPES } from '../../lib/runQueryRetry.mjs' + +test('an empty class-connectivity result is retried once, like a -1 (#66)', () => { + const zero = '{"count":0,"count_status":"exact","rows":[]}' + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity', limit: 2500 }, zero), true) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'UpstreamClassConnectivity' }, { count: 0 }), true) + assert.deepEqual([...SUSPICIOUS_ZERO_QUERY_TYPES].sort(), ['DownstreamClassConnectivity', 'UpstreamClassConnectivity']) +}) + +test('every other zero is still a zero', () => { + const zero = '{"count":0,"count_status":"exact","rows":[]}' + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'NeuronsPartHere' }, zero), false) + assert.equal(isSuspiciousZeroRunQuery('get_term_info', { id: 'FBbt_00100247' }, zero), false) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity' }, '{"count":3886,"rows":[{}]}'), false) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity' }, '{"count":0,"rows":[{"id":"x"}]}'), false) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity' }, '{"count":-1,"rows":[]}'), false) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity' }, '{"error":"boom","count":0}'), false) + assert.equal(isSuspiciousZeroRunQuery('run_query', { id: 'FBbt_00100247', query_type: 'DownstreamClassConnectivity' }, 'not json'), false) +})