diff --git a/lib/followOns.mjs b/lib/followOns.mjs index 25d2d46..6501c40 100644 --- a/lib/followOns.mjs +++ b/lib/followOns.mjs @@ -241,9 +241,18 @@ export function buildFollowOns(ledger, { maxChips = 6, countEstimates = [] } = { // ask chips from the term's real available-data queries (largest first). // Keep anything that is not an exact zero: a -1 means the total is not known // exactly, never that the data is absent. - const queries = (t.digest?.queries || []).filter(q => - askPrompt(q, label) && !(countKindOf(q) === 'exact' && !(q.count > 0)) && - !ranThisTurn.has(`${t.id}::${String(q.query_type).toLowerCase()}`)) + const offerable = (t.digest?.queries || []).filter(q => + askPrompt(q, label) && !(countKindOf(q) === 'exact' && !(q.count > 0))) + const unrun = offerable.filter(q => !ranThisTurn.has(`${t.id}::${String(q.query_type).toLowerCase()}`)) + // Suppressing what the answer was built from is right until it is all of it. + // The olfactory system offers exactly two queries, PartsOf and + // NeuronsPartHere; when the absence check runs both — about one welcome-screen + // answer in three — every chip was suppressed and the reader was left an + // answer with nothing to click. A query the prose summarised is still worth + // offering, because the chip opens the rows themselves; only offering it + // ALONGSIDE an unasked one is worse. So: the unrun queries where there are + // any, and otherwise the ones that ran. + const queries = unrun.length ? unrun : offerable // Largest first — the count is VFB's own statement of how much data is // behind the offer, and my phrasing quality is not a reason to overrule it. // Ties go to a type this file can phrase exactly. diff --git a/tests/unit/followOns.test.mjs b/tests/unit/followOns.test.mjs index 8e1ae60..115d9e7 100644 --- a/tests/unit/followOns.test.mjs +++ b/tests/unit/followOns.test.mjs @@ -276,6 +276,32 @@ test('suppression is per term, not per query type', () => { 'the lobula was never asked about — its chip must survive') }) +test('when the answer ran EVERY offerable query, the chips come back rather than vanish', () => { + // The olfactory system offers exactly two: PartsOf and NeuronsPartHere. When + // the absence check runs both — about one welcome-screen answer in three — the + // reader was left an answer with nothing at all to click (battery WS3). + const OLFACTORY = { + id: 'FBbt_00007688', + label: 'olfactory system', + digest: { + name: 'olfactory system', + queries: [ + { query_type: 'NeuronsPartHere', label: 'Neurons with some part here', count: 315 }, + { query_type: 'PartsOf', label: 'Parts', count: 368 } + ] + } + } + const ran = qt => ({ id: 's', tool: 'vfb_run_query', args: { id: 'FBbt_00007688', query_type: qt }, status: 'satisfied' }) + const both = buildFollowOns({ terms: { 'olfactory system': OLFACTORY }, plan: [ran('NeuronsPartHere'), ran('PartsOf')] }) + .chips.filter(c => c.kind === 'ask') + assert.deepEqual(both.map(c => c.query_type), ['PartsOf', 'NeuronsPartHere'], 'largest first, as usual') + assert.ok(both.every(c => c.id === 'FBbt_00007688'), 'and still addressed') + // One of the two run is still ordinary suppression: the unasked one wins alone. + const one = buildFollowOns({ terms: { 'olfactory system': OLFACTORY }, plan: [ran('PartsOf')] }) + .chips.filter(c => c.kind === 'ask') + assert.deepEqual(one.map(c => c.query_type), ['NeuronsPartHere']) +}) + test('a planned query with no id suppresses nothing', () => { // `::type` steps exist (a planner step naming a query with no target). They must // not mute a chip for a term they may not even be about.