Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/JOURNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
8 changes: 8 additions & 0 deletions src/shared/case-dossier.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions src/shared/case-synthesis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 && {
Expand All @@ -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
});
}
Expand Down
17 changes: 15 additions & 2 deletions src/shared/corpus-prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.',
Expand Down
16 changes: 16 additions & 0 deletions tests/case-dossier.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 16 additions & 1 deletion tests/case-synthesis.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }];
Expand Down
13 changes: 12 additions & 1 deletion tests/corpus-prompts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down