diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 001527c..486103c 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -19,6 +19,35 @@ or files, and the "so-what" for future readers. --- +## 2026-07-16 — The scope question never reached the LLM; proposals were never asked for (27 S.1/S.2) + +Tags: `bug`, `design`. + +Two quiet corpus-synthesis defects found by the Phase-27 investigation: + +**`dossier.scope.question` was always `''`.** `synthesis-block.js` read +it for every map/reduce/publish call, but `buildCaseDossier` never +emitted a `scope` key — the authored question lives on the case +entity's `authored_fields.scope_question` and only rendered in the +case header. Every synthesis ran unsteered. Fix: the dossier now emits +`scope.question` at assembly (S.2). Lesson: a consumer reading a key +the producer never writes fails silently as a falsy default — worth a +grep when wiring a new prompt input. + +**Zero relationship proposals was prompt-shaped, not model-shaped.** +The reduce deliverables list omitted proposals entirely; the only +mention was 20.6's restriction ("OMIT rather than guessing"). An +optional schema slot whose sole prompt mention is a prohibition +predictably returns empty — the H.4 hypothesis prompt proved the +affirmative form works. Fix: an explicit cross-article directive plus +short per-article `art` keys in the digest (S.1), and +`CORPUS_PROMPT_VERSION` bumped to `corpus-v2` — 20.6 changed the +prompt WITHOUT bumping it, so broken-era briefs never showed a stale +chip. The version constant now carries the discipline note: any +prompt/schema/digest change bumps it. + +--- + ## 2026-07-16 — Hypothesis identity is the label; case delete doesn't cascade the map (26 H.1) Tags: `design`. diff --git a/src/shared/case-dossier.js b/src/shared/case-dossier.js index fdf335c..be1ea4c 100644 --- a/src/shared/case-dossier.js +++ b/src/shared/case-dossier.js @@ -1012,9 +1012,17 @@ export function buildCaseDossier(data, generatedAt) { const articleRows = deriveArticleRows(data); const shape = buildShapeOfKnowledge(data); const evidence = buildEvidenceGroups(data, articleRows); + // The author's scope question (27 S.2). Consumers read + // `dossier.scope.question` — synthesis-block always has, but until + // this key existed the value was silently '' and the case question + // never reached any LLM call (JOURNAL 2026-07-16). + const caseEntity = (data.entitiesById || {})[data.case && data.case.id] || null; + const scopeField = caseEntity && caseEntity.authored_fields + && caseEntity.authored_fields.scope_question; return { case: data.case, generated_at: generatedAt ?? null, + scope: { question: (scopeField && scopeField.value ? String(scopeField.value).trim() : '') }, coverage: { articles: evidence.coverage.articles, articles_with_claims: evidence.coverage.articles_with_claims, diff --git a/src/shared/case-synthesis.js b/src/shared/case-synthesis.js index a6c1b2b..37994f6 100644 --- a/src/shared/case-synthesis.js +++ b/src/shared/case-synthesis.js @@ -96,11 +96,24 @@ export const DIGEST_CLAIM_CAP = 150; export function digestDossier(dossier, { claims = [] } = {}) { const shape = dossier.shape_of_knowledge || {}; const knots = dossier.knots || {}; - const claimIndex = claims.slice(0, DIGEST_CLAIM_CAP).map((c) => ({ + // Claims carry a short per-article key (`art: 'A1'`) instead of a + // 64-hex hash (27 S.1): cross-ARTICLE relationship proposals need + // the model to see which claims come from different articles, and + // the short key makes pairs identifiable at a glance (and shrinks + // the digest). `articles` maps the keys back to hashes. + const capped = claims.slice(0, DIGEST_CLAIM_CAP); + const artKeyByHash = new Map(); + for (const c of capped) { + const h = c.article_hash || null; + if (h && !artKeyByHash.has(h)) artKeyByHash.set(h, `A${artKeyByHash.size + 1}`); + } + const claimIndex = capped.map((c) => ({ id: c.id, text: (c.text || '').slice(0, 160), - article_hash: c.article_hash || null + art: c.article_hash ? artKeyByHash.get(c.article_hash) : null })); + const articleKeys = {}; + for (const [hash, key] of artKeyByHash) articleKeys[key] = hash; return JSON.stringify({ coverage: dossier.coverage || {}, distribution: (shape.distribution && { @@ -114,6 +127,7 @@ export function digestDossier(dossier, { claims = [] } = {}) { notes: (k.edges || []).map((e) => e.note).filter(Boolean) })), claims: claimIndex, + articles: articleKeys, claim_count: claimIndex.length }); } diff --git a/src/shared/corpus-prompts.js b/src/shared/corpus-prompts.js index 54f2afe..43a7772 100644 --- a/src/shared/corpus-prompts.js +++ b/src/shared/corpus-prompts.js @@ -24,7 +24,11 @@ import { CLAIM_RELATIONSHIPS } from './assessment-taxonomy.js'; -export const CORPUS_PROMPT_VERSION = 'corpus-v1'; +// DISCIPLINE (the 20.6 lesson, institutionalized 27 S.1): ANY change +// to prompt text, tool schemas, or digest shape bumps this version. +// It rides corpusInputHash, so every stored brief correctly goes stale +// and shows the re-run chip; recompute stays human-triggered. +export const CORPUS_PROMPT_VERSION = 'corpus-v2'; export const MAP_TOOL_NAME = 'emit_corpus_extract'; export const REDUCE_TOOL_NAME = 'emit_case_brief'; export const HYPOTHESIS_EDGE_PROMPT_VERSION = 'hyp-edges-v1'; @@ -243,7 +247,16 @@ export function buildReduceSystemPrompt({ caseName = '', scopeQuestion = '' } = scopeQuestion ? `The question it investigates: "${scopeQuestion}".` : '', 'Produce a grounded brief: a neutral summary, the positions present (attributed to the', 'member articles that hold them), the cruxes of disagreement with each side\'s view SIDE', - 'BY SIDE, the load-bearing claims, and the coverage gaps.', + 'BY SIDE, the load-bearing claims, the coverage gaps, and reviewable PROPOSALS.', + 'PROPOSALS (a human accepts or rejects each — nothing you propose is applied on its own):', + '- Scan the digest\'s claims index for pairs of claims from DIFFERENT articles (the `art`', + ' key differs) that contradict, support, update, or duplicate each other. Propose EVERY', + ' such pair you find as a `relationship` proposal citing both existing claim ids —', + ' cross-article links are the corpus\'s connective tissue and the primary thing this', + ' synthesis adds over reading articles one at a time. Do not re-propose pairs already', + ' listed under `contradictions` in the digest.', + '- Propose `is_key` for existing claims the whole case turns on, and `claim` for', + ' load-bearing assertions in the extracts that have no claim yet.', 'HARD RULES (docs/PHILOSOPHY.md):', '- NEVER output a verdict, score, probability, or "who is right". Disagreement is DATA —', ' present it, do not resolve it.', diff --git a/tests/case-dossier.test.mjs b/tests/case-dossier.test.mjs index 16392d1..d97dcd1 100644 --- a/tests/case-dossier.test.mjs +++ b/tests/case-dossier.test.mjs @@ -490,6 +490,22 @@ test('case-dossier: timeline gaps — the three cross-axis anomalies', async () assert.equal(gaps.filter((g) => g.kind === 'published-before-occurred' && g.proposition_id === propY.id).length, 0); }); +test('case-dossier: the authored scope question rides the dossier (27 S.2)', async () => { + resetState(); + const { kase } = await seedOrbit(); + // Author the scope question the way the sidepanel does. + const all = await EntityModel.getAll(); + all[kase.id].authored_fields = { scope_question: { value: 'Where did the outbreak begin?' } }; + _stateStore.set('entities', JSON.stringify(all)); + const dossier = await assembleCaseDossier(kase.id, { generatedAt: GENERATED, ...injected() }); + assert.equal(dossier.scope.question, 'Where did the outbreak begin?'); + // Unauthored → empty string, never fabricated (P4). + resetState(); + const bare = await EntityModel.create({ name: 'Bare case', type: 'case' }); + const bareDossier = await assembleCaseDossier(bare.id, { generatedAt: GENERATED, articles: [], auditRuns: [], predictions: [], resolutions: [] }); + assert.equal(bareDossier.scope.question, ''); +}); + test('case-dossier: coverage counts on every section', async () => { resetState(); const { dossier } = await assembleFixture(); diff --git a/tests/case-synthesis.test.mjs b/tests/case-synthesis.test.mjs index 6a46e5e..b9ca60c 100644 --- a/tests/case-synthesis.test.mjs +++ b/tests/case-synthesis.test.mjs @@ -99,13 +99,28 @@ test('case-synthesis: digestDossier surfaces the claim index for the reduce stag assert.equal(digest.claim_count, 1); assert.equal(digest.claims[0].id, 'c1'); assert.ok(digest.claims[0].text.includes('lab reported')); - assert.equal(digest.claims[0].article_hash, 'A'); // No claims passed → empty index (not a crash), count 0. const empty = JSON.parse(CS.digestDossier(dossier)); assert.deepEqual(empty.claims, []); assert.equal(empty.claim_count, 0); }); +test('case-synthesis: digest claims carry short per-article keys so cross-article pairs are identifiable (27 S.1)', () => { + const dossier = { coverage: {}, shape_of_knowledge: {}, knots: {}, orbit: {} }; + const digest = JSON.parse(CS.digestDossier(dossier, { claims: [ + { id: 'c1', text: 'One.', article_hash: 'a'.repeat(64) }, + { id: 'c2', text: 'Two.', article_hash: 'b'.repeat(64) }, + { id: 'c3', text: 'Three.', article_hash: 'a'.repeat(64) }, + { id: 'c4', text: 'Hashless.' } + ] })); + assert.deepEqual(digest.claims.map((c) => c.art), ['A1', 'A2', 'A1', null], + 'same article → same key; no hash → null, never fabricated'); + assert.deepEqual(digest.articles, { A1: 'a'.repeat(64), A2: 'b'.repeat(64) }, + 'keys resolve back to the real hashes'); + assert.ok(!JSON.stringify(digest.claims).includes('a'.repeat(64)), + 'the 64-hex hash no longer rides every claim entry'); +}); + test('case-synthesis: corpusInputHash is order-insensitive but sensitive to membership + prompt', async () => { const a = [{ article_hash: 'h1' }, { article_hash: 'h2' }]; const aRev = [{ article_hash: 'h2' }, { article_hash: 'h1' }]; diff --git a/tests/corpus-prompts.test.mjs b/tests/corpus-prompts.test.mjs index 4d084e5..40ddeb3 100644 --- a/tests/corpus-prompts.test.mjs +++ b/tests/corpus-prompts.test.mjs @@ -8,7 +8,18 @@ const CP = await import('../src/shared/corpus-prompts.js'); test('corpus-prompts: tool names + version pinned', () => { assert.equal(CP.MAP_TOOL_NAME, 'emit_corpus_extract'); assert.equal(CP.REDUCE_TOOL_NAME, 'emit_case_brief'); - assert.equal(CP.CORPUS_PROMPT_VERSION, 'corpus-v1'); + assert.equal(CP.CORPUS_PROMPT_VERSION, 'corpus-v2'); +}); + +test('corpus-prompts: the reduce prompt AFFIRMATIVELY asks for cross-article relationship proposals (27 S.1)', () => { + const sys = CP.buildReduceSystemPrompt({ caseName: 'Origins', scopeQuestion: 'Where?' }); + assert.match(sys, /pairs of claims from DIFFERENT articles/); + assert.match(sys, /Propose EVERY\s+such pair/); + assert.match(sys, /`art`\s+key/); + assert.match(sys, /nothing you propose is applied on its own/); + // The 20.6 discipline stays: never guess an id. + assert.match(sys, /OMIT it rather than guessing/); + assert.match(sys, /NEVER output a verdict, score, probability/); }); test('corpus-prompts: NEITHER tool schema carries a numeric score/confidence field (P2)', () => {