@unson/brainbase-mcp 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ import { type JudgmentModelOutcome, type JudgmentModelOutcomeSummary } from './judgment-model-outcome.js';
2
3
  /**
3
4
  * Judgment frame (brainbase-project ADR-014).
4
5
  *
@@ -91,7 +92,9 @@ export interface JudgmentFrameIssue {
91
92
  readonly message: string;
92
93
  }
93
94
  /** Exceptions that ADR-014 F5 returns to a human. The host decides when they block. */
94
- export type JudgmentFrameEscalationCode = 'objective_unanchored' | 'chosen_violates_philosophy' | 'chosen_philosophy_tension';
95
+ export type JudgmentFrameEscalationCode = 'objective_unanchored' | 'chosen_violates_philosophy' | 'chosen_philosophy_tension'
96
+ /** The chosen option predicts with a world model refuted at its current revision (F5, F6). */
97
+ | 'chosen_uses_refuted_model';
95
98
  export interface JudgmentFrameEscalation {
96
99
  readonly code: JudgmentFrameEscalationCode;
97
100
  readonly refs: readonly string[];
@@ -101,6 +104,8 @@ export interface JudgmentFrameValidation {
101
104
  readonly issues: readonly JudgmentFrameIssue[];
102
105
  readonly escalations: readonly JudgmentFrameEscalation[];
103
106
  }
107
+ /** True when text looks like it carries a credential. Shared by public frames and outcome records. */
108
+ export declare function containsJudgmentFrameCredential(value: string): boolean;
104
109
  /**
105
110
  * Compute the digest for the exact two-field public projection.
106
111
  * The response adds this digest; it is intentionally separate from the
@@ -118,13 +123,16 @@ export declare function judgmentFrameRecordDigest(value: JudgmentFrameRecord): `
118
123
  * Excluded records are returned with a reason so a shrinking catalog is visible.
119
124
  */
120
125
  export declare function buildJudgmentFrameCatalog(records: readonly JudgmentFrameSourceRecord[]): JudgmentFrameCatalogBuild;
121
- /** One line per item, grouped by kind, for the model-facing prompt. */
122
- export declare function renderJudgmentFrameCatalog(catalog: JudgmentFrameCatalog): string;
126
+ /**
127
+ * One line per item, grouped by kind, for the model-facing prompt. Outcome
128
+ * summaries, when given, annotate world models; they are not part of the digest.
129
+ */
130
+ export declare function renderJudgmentFrameCatalog(catalog: JudgmentFrameCatalog, outcomes?: ReadonlyMap<string, JudgmentModelOutcomeSummary>): string;
123
131
  /**
124
132
  * Check that a frame record only uses catalog references and that every
125
133
  * selection is used in at least one option with the role of its kind.
126
134
  */
127
- export declare function validateJudgmentFrameRecord(value: unknown, catalog: JudgmentFrameCatalog): JudgmentFrameValidation;
135
+ export declare function validateJudgmentFrameRecord(value: unknown, catalog: JudgmentFrameCatalog, outcomes?: ReadonlyMap<string, JudgmentModelOutcomeSummary>): JudgmentFrameValidation;
128
136
  export declare const judgmentFrameTools: Tool[];
129
137
  export type JudgmentFrameRecordsPage = {
130
138
  readonly status: 'ok';
@@ -134,9 +142,38 @@ export type JudgmentFrameRecordsPage = {
134
142
  readonly code: string;
135
143
  readonly message: string;
136
144
  };
145
+ export type JudgmentModelOutcomesPage = {
146
+ readonly status: 'ok';
147
+ readonly records: readonly unknown[];
148
+ } | {
149
+ readonly status: 'unavailable' | 'error';
150
+ readonly code: string;
151
+ readonly message: string;
152
+ };
153
+ export type JudgmentModelOutcomeAppendResult = {
154
+ readonly status: 'ok';
155
+ readonly id: string;
156
+ readonly recorded_at: string;
157
+ } | {
158
+ readonly status: 'unavailable' | 'error';
159
+ readonly code: string;
160
+ readonly message: string;
161
+ };
137
162
  export interface JudgmentFrameToolDependencies {
138
163
  /** Read every record of one kind visible to the caller, at most `limit`. */
139
164
  readonly loadRecords: (kind: JudgmentFrameKind, limit: number) => Promise<JudgmentFrameRecordsPage>;
165
+ /**
166
+ * Read every stored world-model outcome visible to the caller (ADR-014 F6).
167
+ * When omitted, the catalog and escalations behave as before.
168
+ */
169
+ readonly loadModelOutcomes?: () => Promise<JudgmentModelOutcomesPage>;
170
+ }
171
+ export interface JudgmentModelOutcomeToolDependencies extends JudgmentFrameToolDependencies {
172
+ readonly loadModelOutcomes: () => Promise<JudgmentModelOutcomesPage>;
173
+ /** Append one validated outcome. The host owns id, time, author, authorization, and storage. */
174
+ readonly appendModelOutcome: (outcome: JudgmentModelOutcome & {
175
+ readonly digest: `sha256:${string}`;
176
+ }) => Promise<JudgmentModelOutcomeAppendResult>;
140
177
  }
141
178
  export type JudgmentFrameToolResult = {
142
179
  readonly status: 'ok';
@@ -150,5 +187,13 @@ export type JudgmentFrameToolResult = {
150
187
  };
151
188
  };
152
189
  export declare function handleJudgmentFrameToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentFrameToolDependencies): Promise<JudgmentFrameToolResult | null>;
190
+ /**
191
+ * Opt-in tool for hosts that store world-model outcomes (ADR-014 F6).
192
+ * Published separately so a host without storage does not expose it.
193
+ */
194
+ export declare const judgmentModelOutcomeTools: Tool[];
195
+ export declare function handleJudgmentModelOutcomeToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentModelOutcomeToolDependencies): Promise<JudgmentFrameToolResult | null>;
196
+ export { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
197
+ export type { JudgmentModelOutcome, JudgmentModelOutcomeAlternative, JudgmentModelOutcomeEvidenceKind, JudgmentModelOutcomeEvidenceRef, JudgmentModelOutcomeIssue, JudgmentModelOutcomeIssueCode, JudgmentModelOutcomeStoredRecord, JudgmentModelOutcomeSummary, JudgmentModelOutcomeValidation, JudgmentModelOutcomeVerdict, } from './judgment-model-outcome.js';
153
198
  export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
154
199
  export type { JudgmentFrameReadDependencies, JudgmentFrameReadRecord, } from './judgment-frame-read.js';
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
2
3
  /**
3
4
  * Judgment frame (brainbase-project ADR-014).
4
5
  *
@@ -50,7 +51,8 @@ const PUBLIC_FRAME_SECRET_PATTERNS = [
50
51
  /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/u,
51
52
  ];
52
53
  const PUBLIC_FRAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
53
- function containsPublicFrameCredential(value) {
54
+ /** True when text looks like it carries a credential. Shared by public frames and outcome records. */
55
+ export function containsJudgmentFrameCredential(value) {
54
56
  return PUBLIC_FRAME_SECRET_PATTERNS.some((pattern) => pattern.test(value));
55
57
  }
56
58
  function publicFrameRecord(value) {
@@ -164,13 +166,24 @@ const KIND_HEADINGS = {
164
166
  objective: '目的(criterionとして使う)',
165
167
  model: '世界モデル(predictionとして使う。仮説であり、検証済みとは限らない)',
166
168
  };
167
- /** One line per item, grouped by kind, for the model-facing prompt. */
168
- export function renderJudgmentFrameCatalog(catalog) {
169
+ function renderOutcomeSummary(summary) {
170
+ if (!summary)
171
+ return '';
172
+ const counts = `支持${summary.supports}・反証${summary.refutes}・判定不能${summary.inconclusive}`;
173
+ const refuted = summary.refuted_when.length > 0 ? `。反証の条件:${summary.refuted_when.join('/')}` : '';
174
+ return `(この版の結果:${counts}${refuted})`;
175
+ }
176
+ /**
177
+ * One line per item, grouped by kind, for the model-facing prompt. Outcome
178
+ * summaries, when given, annotate world models; they are not part of the digest.
179
+ */
180
+ export function renderJudgmentFrameCatalog(catalog, outcomes = new Map()) {
169
181
  const lines = [`judgment frame catalog ${catalog.digest}`];
170
182
  for (const kind of KINDS) {
171
183
  lines.push('', `## ${KIND_HEADINGS[kind]}`);
172
184
  for (const item of catalog.items.filter((candidate) => candidate.kind === kind)) {
173
- lines.push(`- ${item.id} [${item.status}] ${item.text}${item.applies_when ? `(適用:${item.applies_when})` : ''}`);
185
+ const outcome = kind === 'model' ? renderOutcomeSummary(outcomes.get(item.id)) : '';
186
+ lines.push(`- ${item.id} [${item.status}] ${item.text}${item.applies_when ? `(適用:${item.applies_when})` : ''}${outcome}`);
174
187
  }
175
188
  }
176
189
  return lines.join('\n');
@@ -179,7 +192,7 @@ export function renderJudgmentFrameCatalog(catalog) {
179
192
  * Check that a frame record only uses catalog references and that every
180
193
  * selection is used in at least one option with the role of its kind.
181
194
  */
182
- export function validateJudgmentFrameRecord(value, catalog) {
195
+ export function validateJudgmentFrameRecord(value, catalog, outcomes = new Map()) {
183
196
  const issues = [];
184
197
  const issue = (code, path, message) => issues.push({ code, path, message });
185
198
  if (!isRecord(value) || value.record_version !== JUDGMENT_FRAME_RECORD_VERSION) {
@@ -205,7 +218,7 @@ export function validateJudgmentFrameRecord(value, catalog) {
205
218
  if (PUBLIC_FRAME_CONTROL_CHARACTERS.test(candidate)) {
206
219
  issue('frame_public_invalid', `public_frame.${key}`, `${key} contains control characters`);
207
220
  }
208
- if (containsPublicFrameCredential(candidate)) {
221
+ if (containsJudgmentFrameCredential(candidate)) {
209
222
  issue('frame_public_invalid', `public_frame.${key}`, `${key} contains a credential-like value`);
210
223
  }
211
224
  }
@@ -311,6 +324,12 @@ export function validateJudgmentFrameRecord(value, catalog) {
311
324
  escalations.push({ code: 'chosen_violates_philosophy', refs: violated });
312
325
  if (tension.length > 0)
313
326
  escalations.push({ code: 'chosen_philosophy_tension', refs: tension });
327
+ const refuted = [...new Set(chosenUses
328
+ .filter((use) => isRecord(use) && use.role === 'prediction'
329
+ && selected.get(String(use.ref)) === 'model' && (outcomes.get(String(use.ref))?.refutes ?? 0) > 0)
330
+ .map((use) => String(use.ref)))];
331
+ if (refuted.length > 0)
332
+ escalations.push({ code: 'chosen_uses_refuted_model', refs: refuted });
314
333
  }
315
334
  return { valid: issues.length === 0, issues, escalations };
316
335
  }
@@ -415,6 +434,24 @@ async function loadFrameCatalog(dependencies) {
415
434
  }
416
435
  return buildJudgmentFrameCatalog(records);
417
436
  }
437
+ async function loadOutcomeSummaries(dependencies, catalog) {
438
+ if (!dependencies.loadModelOutcomes)
439
+ return new Map();
440
+ let page;
441
+ try {
442
+ page = await dependencies.loadModelOutcomes();
443
+ }
444
+ catch (error) {
445
+ return toolError('unavailable', 'judgment_model_outcomes_unavailable', `World model outcomes could not be read: ${error instanceof Error ? error.message : String(error)}`);
446
+ }
447
+ if (page.status !== 'ok')
448
+ return toolError(page.status, page.code, page.message);
449
+ const parsed = parseJudgmentModelOutcomeRecords(page.records);
450
+ if (!parsed.ok) {
451
+ return toolError('error', 'judgment_model_outcomes_invalid', `Stored world model outcome ${parsed.index} is invalid: ${parsed.message}`);
452
+ }
453
+ return summarizeJudgmentModelOutcomes(parsed.records, catalog);
454
+ }
418
455
  export async function handleJudgmentFrameToolCall(name, args, dependencies) {
419
456
  if (name !== 'brainbase_judgment_frame_catalog' && name !== 'brainbase_judgment_frame_record')
420
457
  return null;
@@ -425,6 +462,9 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
425
462
  if ('status' in loaded)
426
463
  return loaded;
427
464
  const { catalog, excluded } = loaded;
465
+ const outcomes = await loadOutcomeSummaries(dependencies, catalog);
466
+ if (!(outcomes instanceof Map))
467
+ return outcomes;
428
468
  if (name === 'brainbase_judgment_frame_catalog') {
429
469
  return {
430
470
  status: 'ok',
@@ -433,11 +473,12 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
433
473
  catalog_digest: catalog.digest,
434
474
  counts: Object.fromEntries(KINDS.map((kind) => [kind, catalog.items.filter((item) => item.kind === kind).length])),
435
475
  excluded_count: excluded.length,
436
- catalog: renderJudgmentFrameCatalog(catalog),
476
+ ...(dependencies.loadModelOutcomes ? { models_with_outcomes: outcomes.size } : {}),
477
+ catalog: renderJudgmentFrameCatalog(catalog, outcomes),
437
478
  },
438
479
  };
439
480
  }
440
- const validation = validateJudgmentFrameRecord(args, catalog);
481
+ const validation = validateJudgmentFrameRecord(args, catalog, outcomes);
441
482
  if (!validation.valid) {
442
483
  return toolError('error', 'judgment_frame_invalid', 'The judgment frame does not match the catalog contract', {
443
484
  issues: validation.issues,
@@ -463,4 +504,100 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
463
504
  },
464
505
  };
465
506
  }
507
+ const outcomeTextSchema = { type: 'string', minLength: 1, maxLength: 2_000 };
508
+ /**
509
+ * Opt-in tool for hosts that store world-model outcomes (ADR-014 F6).
510
+ * Published separately so a host without storage does not expose it.
511
+ */
512
+ export const judgmentModelOutcomeTools = [{
513
+ name: 'brainbase_judgment_model_outcome_record',
514
+ description: 'After the result of a judgment is observed, record whether it supports or refutes a world model that the judgment used as a prediction. Read brainbase_judgment_frame_catalog first and pass its catalog_digest and the model id at its current revision. State the prediction made from the model, what was observed, the conditions (facility, period, state) under which the result holds, and at least one evidence reference. A refutation must say, for each of measurement_error, execution_difference, and external_change, why it does not explain the gap; a prediction gap alone is not a refutation. This does not change the model, its revision, or its adoption; adoption and verification stay human decisions. The tool does not judge whether the verdict is semantically right.',
515
+ inputSchema: {
516
+ type: 'object',
517
+ additionalProperties: false,
518
+ properties: {
519
+ record_version: { type: 'string', const: JUDGMENT_MODEL_OUTCOME_RECORD_VERSION },
520
+ catalog_digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
521
+ model: {
522
+ type: 'object',
523
+ additionalProperties: false,
524
+ properties: {
525
+ id: { type: 'string', minLength: 1, maxLength: 1_000 },
526
+ revision: { type: 'string', minLength: 1, maxLength: 64 },
527
+ },
528
+ required: ['id', 'revision'],
529
+ },
530
+ verdict: { type: 'string', enum: ['supports', 'refutes', 'inconclusive'] },
531
+ prediction: outcomeTextSchema,
532
+ observed: outcomeTextSchema,
533
+ conditions: outcomeTextSchema,
534
+ evidence_refs: {
535
+ type: 'array',
536
+ minItems: 1,
537
+ maxItems: 12,
538
+ items: {
539
+ type: 'object',
540
+ additionalProperties: false,
541
+ properties: {
542
+ kind: { type: 'string', enum: ['artifact', 'query', 'document', 'judgment'] },
543
+ ref: { type: 'string', minLength: 1, maxLength: 800 },
544
+ label: { type: 'string', minLength: 1, maxLength: 200 },
545
+ },
546
+ required: ['kind', 'ref', 'label'],
547
+ },
548
+ },
549
+ ruled_out: {
550
+ type: 'object',
551
+ additionalProperties: false,
552
+ description: 'Required only when verdict is refutes.',
553
+ properties: Object.fromEntries(JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.map((key) => [key, { type: 'string', minLength: 1, maxLength: 1_000 }])),
554
+ required: [...JUDGMENT_MODEL_OUTCOME_ALTERNATIVES],
555
+ },
556
+ judgment_ref: {
557
+ type: 'object',
558
+ additionalProperties: false,
559
+ properties: { frame_digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' } },
560
+ required: ['frame_digest'],
561
+ },
562
+ },
563
+ required: ['record_version', 'catalog_digest', 'model', 'verdict', 'prediction', 'observed', 'conditions', 'evidence_refs'],
564
+ },
565
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
566
+ }];
567
+ export async function handleJudgmentModelOutcomeToolCall(name, args, dependencies) {
568
+ if (name !== 'brainbase_judgment_model_outcome_record')
569
+ return null;
570
+ const loaded = await loadFrameCatalog(dependencies);
571
+ if ('status' in loaded)
572
+ return loaded;
573
+ const validation = validateJudgmentModelOutcome(args, loaded.catalog, containsJudgmentFrameCredential);
574
+ if (!validation.valid) {
575
+ return toolError('error', 'judgment_model_outcome_invalid', 'The outcome does not match the current catalog contract', {
576
+ issues: validation.issues,
577
+ current_catalog_digest: loaded.catalog.digest,
578
+ });
579
+ }
580
+ const digest = judgmentModelOutcomeDigest(validation.outcome);
581
+ let appended;
582
+ try {
583
+ appended = await dependencies.appendModelOutcome({ ...validation.outcome, digest });
584
+ }
585
+ catch (error) {
586
+ return toolError('unavailable', 'judgment_model_outcome_unavailable', `The outcome could not be stored: ${error instanceof Error ? error.message : String(error)}`);
587
+ }
588
+ if (appended.status !== 'ok')
589
+ return toolError(appended.status, appended.code, appended.message);
590
+ return {
591
+ status: 'ok',
592
+ data: {
593
+ schema_version: 'brainbase-judgment-model-outcome-v1',
594
+ id: appended.id,
595
+ recorded_at: appended.recorded_at,
596
+ model: validation.outcome.model,
597
+ verdict: validation.outcome.verdict,
598
+ digest,
599
+ },
600
+ };
601
+ }
602
+ export { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
466
603
  export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
@@ -0,0 +1,96 @@
1
+ import type { JudgmentFrameCatalog } from './judgment-frame.js';
2
+ /**
3
+ * Judgment outcome feedback for world models (brainbase-project ADR-014 F6).
4
+ *
5
+ * After a judgment that used a world model as a prediction, the observed
6
+ * result is appended as a separate record bound to the model's id and
7
+ * revision. The model itself (revision, validationState, adoptionState) is
8
+ * never changed here: adoption and promotion stay human decisions.
9
+ *
10
+ * Records are checked structurally against the current catalog. Whether a
11
+ * verdict is semantically right is not judged. A refutation must explain why
12
+ * measurement error, execution difference, and external change were ruled
13
+ * out, so a prediction gap alone is never recorded as a refutation.
14
+ */
15
+ export declare const JUDGMENT_MODEL_OUTCOME_RECORD_VERSION: "judgment-model-outcome.v1";
16
+ export type JudgmentModelOutcomeVerdict = 'supports' | 'refutes' | 'inconclusive';
17
+ export type JudgmentModelOutcomeEvidenceKind = 'artifact' | 'query' | 'document' | 'judgment';
18
+ export declare const JUDGMENT_MODEL_OUTCOME_ALTERNATIVES: readonly ["measurement_error", "execution_difference", "external_change"];
19
+ export type JudgmentModelOutcomeAlternative = typeof JUDGMENT_MODEL_OUTCOME_ALTERNATIVES[number];
20
+ export interface JudgmentModelOutcomeEvidenceRef {
21
+ readonly kind: JudgmentModelOutcomeEvidenceKind;
22
+ readonly ref: string;
23
+ readonly label: string;
24
+ }
25
+ export interface JudgmentModelOutcome {
26
+ readonly record_version: typeof JUDGMENT_MODEL_OUTCOME_RECORD_VERSION;
27
+ readonly model: {
28
+ readonly id: string;
29
+ readonly revision: string;
30
+ };
31
+ readonly verdict: JudgmentModelOutcomeVerdict;
32
+ /** What the judgment predicted from this model for this case. */
33
+ readonly prediction: string;
34
+ /** What was actually observed. */
35
+ readonly observed: string;
36
+ /** Facility, period, and state under which the result holds. */
37
+ readonly conditions: string;
38
+ readonly evidence_refs: readonly JudgmentModelOutcomeEvidenceRef[];
39
+ /** Required only for refutes: why each alternative explanation was ruled out. */
40
+ readonly ruled_out?: Readonly<Record<JudgmentModelOutcomeAlternative, string>>;
41
+ readonly judgment_ref?: {
42
+ readonly frame_digest: `sha256:${string}`;
43
+ };
44
+ }
45
+ /** A record as the host stored it: the outcome plus host-owned id, time, and content digest. */
46
+ export interface JudgmentModelOutcomeStoredRecord extends JudgmentModelOutcome {
47
+ readonly id: string;
48
+ readonly recorded_at: string;
49
+ readonly digest: `sha256:${string}`;
50
+ }
51
+ export interface JudgmentModelOutcomeSummary {
52
+ readonly supports: number;
53
+ readonly refutes: number;
54
+ readonly inconclusive: number;
55
+ /** Conditions of the most recent refutations, newest first. */
56
+ readonly refuted_when: readonly string[];
57
+ }
58
+ export type JudgmentModelOutcomeIssueCode = 'outcome_record_invalid' | 'outcome_catalog_mismatch' | 'outcome_model_unknown' | 'outcome_not_model' | 'outcome_revision_mismatch' | 'outcome_ruled_out_missing' | 'outcome_ruled_out_unexpected' | 'outcome_evidence_missing' | 'outcome_text_unsafe';
59
+ export interface JudgmentModelOutcomeIssue {
60
+ readonly code: JudgmentModelOutcomeIssueCode;
61
+ readonly path: string;
62
+ readonly message: string;
63
+ }
64
+ export type JudgmentModelOutcomeValidation = {
65
+ readonly valid: true;
66
+ readonly issues: readonly [];
67
+ readonly outcome: JudgmentModelOutcome;
68
+ } | {
69
+ readonly valid: false;
70
+ readonly issues: readonly JudgmentModelOutcomeIssue[];
71
+ };
72
+ /** Digest of the outcome content. Host-owned id and time are not part of it. */
73
+ export declare function judgmentModelOutcomeDigest(outcome: JudgmentModelOutcome): `sha256:${string}`;
74
+ /**
75
+ * Validate a new outcome against the catalog shown on this turn. The model
76
+ * must be a world model in the catalog, at its current revision.
77
+ */
78
+ export declare function validateJudgmentModelOutcome(value: unknown, catalog: JudgmentFrameCatalog, unsafe?: (text: string) => boolean): JudgmentModelOutcomeValidation;
79
+ /**
80
+ * Parse records returned by the host store. A malformed record or a content
81
+ * digest mismatch is an error, never skipped silently.
82
+ */
83
+ export declare function parseJudgmentModelOutcomeRecords(values: readonly unknown[]): {
84
+ readonly ok: true;
85
+ readonly records: readonly JudgmentModelOutcomeStoredRecord[];
86
+ } | {
87
+ readonly ok: false;
88
+ readonly index: number;
89
+ readonly message: string;
90
+ };
91
+ /**
92
+ * Count outcomes per world model at its current catalog revision. Outcomes
93
+ * recorded against an older revision are not counted: whether a refutation
94
+ * still holds after a revision is a separate judgment.
95
+ */
96
+ export declare function summarizeJudgmentModelOutcomes(records: readonly JudgmentModelOutcomeStoredRecord[], catalog: JudgmentFrameCatalog): ReadonlyMap<string, JudgmentModelOutcomeSummary>;
@@ -0,0 +1,216 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Judgment outcome feedback for world models (brainbase-project ADR-014 F6).
4
+ *
5
+ * After a judgment that used a world model as a prediction, the observed
6
+ * result is appended as a separate record bound to the model's id and
7
+ * revision. The model itself (revision, validationState, adoptionState) is
8
+ * never changed here: adoption and promotion stay human decisions.
9
+ *
10
+ * Records are checked structurally against the current catalog. Whether a
11
+ * verdict is semantically right is not judged. A refutation must explain why
12
+ * measurement error, execution difference, and external change were ruled
13
+ * out, so a prediction gap alone is never recorded as a refutation.
14
+ */
15
+ export const JUDGMENT_MODEL_OUTCOME_RECORD_VERSION = 'judgment-model-outcome.v1';
16
+ export const JUDGMENT_MODEL_OUTCOME_ALTERNATIVES = ['measurement_error', 'execution_difference', 'external_change'];
17
+ const VERDICTS = ['supports', 'refutes', 'inconclusive'];
18
+ const EVIDENCE_KINDS = ['artifact', 'query', 'document', 'judgment'];
19
+ const MAX_STATEMENT = 2_000;
20
+ const MAX_RULED_OUT = 1_000;
21
+ const MAX_EVIDENCE = 12;
22
+ const MAX_EVIDENCE_REF = 800;
23
+ const MAX_EVIDENCE_LABEL = 200;
24
+ const MAX_REFUTED_WHEN = 3;
25
+ const MAX_REFUTED_WHEN_TEXT = 120;
26
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
27
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/u;
28
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+ const nonEmpty = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
30
+ function canonicalJson(value) {
31
+ if (Array.isArray(value))
32
+ return `[${value.map(canonicalJson).join(',')}]`;
33
+ if (isRecord(value)) {
34
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
35
+ }
36
+ return JSON.stringify(value);
37
+ }
38
+ function clip(value, max) {
39
+ const chars = [...value];
40
+ return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}…`;
41
+ }
42
+ /** Digest of the outcome content. Host-owned id and time are not part of it. */
43
+ export function judgmentModelOutcomeDigest(outcome) {
44
+ return `sha256:${createHash('sha256').update(canonicalJson(outcomeContent(outcome)), 'utf8').digest('hex')}`;
45
+ }
46
+ function outcomeContent(value) {
47
+ return {
48
+ record_version: value.record_version,
49
+ model: { id: value.model.id, revision: value.model.revision },
50
+ verdict: value.verdict,
51
+ prediction: value.prediction,
52
+ observed: value.observed,
53
+ conditions: value.conditions,
54
+ evidence_refs: value.evidence_refs.map(({ kind, ref, label }) => ({ kind, ref, label })),
55
+ ...(value.ruled_out === undefined ? {} : {
56
+ ruled_out: Object.fromEntries(JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.map((key) => [key, value.ruled_out[key]])),
57
+ }),
58
+ ...(value.judgment_ref === undefined ? {} : { judgment_ref: { frame_digest: value.judgment_ref.frame_digest } }),
59
+ };
60
+ }
61
+ function checkText(value, path, max, issue, unsafe) {
62
+ if (!nonEmpty(value, max)) {
63
+ issue('outcome_record_invalid', path, `${path} must be a non-empty string of at most ${max} characters`);
64
+ return false;
65
+ }
66
+ if (CONTROL_CHARACTERS.test(value)) {
67
+ issue('outcome_text_unsafe', path, `${path} contains control characters`);
68
+ return false;
69
+ }
70
+ if (unsafe(value)) {
71
+ issue('outcome_text_unsafe', path, `${path} contains a credential-like value`);
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+ /** Structural checks shared by new input and stored records. Catalog checks are separate. */
77
+ function checkOutcomeShape(value, issue, unsafe) {
78
+ if (value.record_version !== JUDGMENT_MODEL_OUTCOME_RECORD_VERSION) {
79
+ issue('outcome_record_invalid', 'record_version', `record_version must be ${JUDGMENT_MODEL_OUTCOME_RECORD_VERSION}`);
80
+ }
81
+ const model = isRecord(value.model) ? value.model : null;
82
+ if (!model || !nonEmpty(model.id, 1_000) || !nonEmpty(model.revision, 64)) {
83
+ issue('outcome_record_invalid', 'model', 'model needs id and revision');
84
+ }
85
+ if (!VERDICTS.includes(value.verdict)) {
86
+ issue('outcome_record_invalid', 'verdict', 'verdict must be supports, refutes, or inconclusive');
87
+ }
88
+ checkText(value.prediction, 'prediction', MAX_STATEMENT, issue, unsafe);
89
+ checkText(value.observed, 'observed', MAX_STATEMENT, issue, unsafe);
90
+ checkText(value.conditions, 'conditions', MAX_STATEMENT, issue, unsafe);
91
+ const evidence = Array.isArray(value.evidence_refs) ? value.evidence_refs : null;
92
+ if (!evidence || evidence.length === 0) {
93
+ issue('outcome_evidence_missing', 'evidence_refs', 'an outcome needs at least one evidence reference');
94
+ }
95
+ else if (evidence.length > MAX_EVIDENCE) {
96
+ issue('outcome_record_invalid', 'evidence_refs', `evidence_refs allows at most ${MAX_EVIDENCE} entries`);
97
+ }
98
+ else {
99
+ evidence.forEach((entry, index) => {
100
+ const path = `evidence_refs[${index}]`;
101
+ if (!isRecord(entry) || !EVIDENCE_KINDS.includes(entry.kind)) {
102
+ issue('outcome_record_invalid', `${path}.kind`, 'evidence kind must be artifact, query, document, or judgment');
103
+ return;
104
+ }
105
+ checkText(entry.ref, `${path}.ref`, MAX_EVIDENCE_REF, issue, unsafe);
106
+ checkText(entry.label, `${path}.label`, MAX_EVIDENCE_LABEL, issue, unsafe);
107
+ });
108
+ }
109
+ if (value.verdict === 'refutes') {
110
+ const ruledOut = isRecord(value.ruled_out) ? value.ruled_out : null;
111
+ for (const key of JUDGMENT_MODEL_OUTCOME_ALTERNATIVES) {
112
+ if (!ruledOut || ruledOut[key] === undefined) {
113
+ issue('outcome_ruled_out_missing', `ruled_out.${key}`, `a refutation must say why ${key} does not explain the gap`);
114
+ }
115
+ else {
116
+ checkText(ruledOut[key], `ruled_out.${key}`, MAX_RULED_OUT, issue, unsafe);
117
+ }
118
+ }
119
+ for (const key of Object.keys(ruledOut ?? {})) {
120
+ if (!JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.includes(key)) {
121
+ issue('outcome_record_invalid', `ruled_out.${key}`, 'ruled_out contains an unknown field');
122
+ }
123
+ }
124
+ }
125
+ else if (value.ruled_out !== undefined) {
126
+ issue('outcome_ruled_out_unexpected', 'ruled_out', 'ruled_out is only for refutes');
127
+ }
128
+ if (value.judgment_ref !== undefined) {
129
+ const ref = isRecord(value.judgment_ref) ? value.judgment_ref : null;
130
+ if (!ref || typeof ref.frame_digest !== 'string' || !DIGEST_PATTERN.test(ref.frame_digest) || Object.keys(ref).length !== 1) {
131
+ issue('outcome_record_invalid', 'judgment_ref', 'judgment_ref needs only frame_digest as sha256');
132
+ }
133
+ }
134
+ }
135
+ /**
136
+ * Validate a new outcome against the catalog shown on this turn. The model
137
+ * must be a world model in the catalog, at its current revision.
138
+ */
139
+ export function validateJudgmentModelOutcome(value, catalog, unsafe = () => false) {
140
+ const issues = [];
141
+ const issue = (code, path, message) => issues.push({ code, path, message });
142
+ if (!isRecord(value)) {
143
+ issue('outcome_record_invalid', '$', 'outcome must be an object');
144
+ return { valid: false, issues };
145
+ }
146
+ if (value.catalog_digest !== catalog.digest) {
147
+ issue('outcome_catalog_mismatch', 'catalog_digest', 'catalog_digest does not match the current catalog');
148
+ }
149
+ checkOutcomeShape(value, issue, unsafe);
150
+ const model = isRecord(value.model) ? value.model : {};
151
+ if (typeof model.id === 'string') {
152
+ const item = catalog.items.find((candidate) => candidate.id === model.id);
153
+ if (!item) {
154
+ issue('outcome_model_unknown', 'model.id', `${model.id} is not in the catalog`);
155
+ }
156
+ else if (item.kind !== 'model') {
157
+ issue('outcome_not_model', 'model.id', `${model.id} is a ${item.kind}; outcomes are recorded only for world models`);
158
+ }
159
+ else if (model.revision !== item.revision) {
160
+ issue('outcome_revision_mismatch', 'model.revision', `${model.id} is at revision ${item.revision}, not ${String(model.revision)}`);
161
+ }
162
+ }
163
+ if (issues.length > 0)
164
+ return { valid: false, issues };
165
+ return { valid: true, issues: [], outcome: outcomeContent(value) };
166
+ }
167
+ /**
168
+ * Parse records returned by the host store. A malformed record or a content
169
+ * digest mismatch is an error, never skipped silently.
170
+ */
171
+ export function parseJudgmentModelOutcomeRecords(values) {
172
+ const records = [];
173
+ for (const [index, value] of values.entries()) {
174
+ if (!isRecord(value) || !nonEmpty(value.id, 200) || !nonEmpty(value.recorded_at, 64)
175
+ || Number.isNaN(Date.parse(value.recorded_at)) || typeof value.digest !== 'string' || !DIGEST_PATTERN.test(value.digest)) {
176
+ return { ok: false, index, message: 'stored outcome needs id, recorded_at, and digest' };
177
+ }
178
+ const issues = [];
179
+ checkOutcomeShape(value, (code, path, message) => issues.push({ code, path, message }), () => false);
180
+ if (issues.length > 0)
181
+ return { ok: false, index, message: `${issues[0].path}: ${issues[0].message}` };
182
+ const content = outcomeContent(value);
183
+ if (judgmentModelOutcomeDigest(content) !== value.digest) {
184
+ return { ok: false, index, message: 'stored outcome digest does not match its content' };
185
+ }
186
+ records.push({ ...content, id: value.id, recorded_at: value.recorded_at, digest: value.digest });
187
+ }
188
+ return { ok: true, records };
189
+ }
190
+ /**
191
+ * Count outcomes per world model at its current catalog revision. Outcomes
192
+ * recorded against an older revision are not counted: whether a refutation
193
+ * still holds after a revision is a separate judgment.
194
+ */
195
+ export function summarizeJudgmentModelOutcomes(records, catalog) {
196
+ const current = new Map(catalog.items.filter((item) => item.kind === 'model').map((item) => [item.id, item.revision]));
197
+ const grouped = new Map();
198
+ for (const record of records) {
199
+ if (current.get(record.model.id) !== record.model.revision)
200
+ continue;
201
+ grouped.set(record.model.id, [...(grouped.get(record.model.id) ?? []), record]);
202
+ }
203
+ const summaries = new Map();
204
+ for (const [id, group] of grouped) {
205
+ const refutations = group
206
+ .filter((record) => record.verdict === 'refutes')
207
+ .sort((left, right) => Date.parse(right.recorded_at) - Date.parse(left.recorded_at) || right.id.localeCompare(left.id));
208
+ summaries.set(id, {
209
+ supports: group.filter((record) => record.verdict === 'supports').length,
210
+ refutes: refutations.length,
211
+ inconclusive: group.filter((record) => record.verdict === 'inconclusive').length,
212
+ refuted_when: refutations.slice(0, MAX_REFUTED_WHEN).map((record) => clip(record.conditions, MAX_REFUTED_WHEN_TEXT)),
213
+ });
214
+ }
215
+ return summaries;
216
+ }