Skip to content

Commit 62d8f2f

Browse files
committed
feat: fact provenance, merkle roots, and statement history MCP
Add FactProvenance enrichment, SHA-256 Merkle tree, payer policy adapter, and get_statement_history MCP tool.
1 parent f6d3be0 commit 62d8f2f

13 files changed

Lines changed: 988 additions & 4 deletions

File tree

docs/mcp.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ Re-observe pages — for scheduled monitoring.
8282
| `interval` | string | | Lookback window in hours |
8383
| `api` | string | | MediaWiki API base URL |
8484

85+
### `get_statement_history`
86+
87+
Track the history of a specific statement across revisions. Returns when the statement — or a semantic neighbor — appeared, disappeared, or changed, with revision IDs, timestamps, an overall status, and a short change summary.
88+
89+
| Parameter | Type | Required | Description |
90+
|---|---|---|---|
91+
| `statement` | string || Raw text statement to track |
92+
| `page` | string || MediaWiki page title |
93+
| `context` | string | | Optional use-context slug |
94+
| `depth` | enum | | `brief`, `detailed` (default), or `forensic` |
95+
| `api` | string | | MediaWiki API base URL. Defaults to English Wikipedia |
96+
97+
Response fields: `statement`, `page`, `context`, `revisions` (array of `revisionId` + `timestamp`), `status` (`present`, `absent`, `modified`, or `contested`), and `history_summary`.
98+
8599
## Protocol details
86100

87101
- **Transport:** stdio (stdin for requests, stdout for responses)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { EvidenceEvent } from "@refract-org/evidence-graph";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
buildSemanticEnrichmentProvenance,
5+
computeSourceSnapshotHash,
6+
enrichEvidenceEvent,
7+
} from "../semantic-enrichment.js";
8+
9+
function makeEvent(overrides?: Partial<EvidenceEvent>): EvidenceEvent {
10+
return {
11+
eventType: "sentence_first_seen",
12+
fromRevisionId: 1,
13+
toRevisionId: 2,
14+
section: "body",
15+
before: "",
16+
after: "The study demonstrated significant results.",
17+
deterministicFacts: [],
18+
layer: "observed",
19+
timestamp: "2026-01-01T00:00:00Z",
20+
...overrides,
21+
};
22+
}
23+
24+
describe("buildSemanticEnrichmentProvenance", () => {
25+
it("produces identical Merkle roots for identical parameters and snapshots", () => {
26+
const params = {
27+
sourceSnapshotHash: computeSourceSnapshotHash(makeEvent()),
28+
parameterFootprint: { depth: "brief", profileHedges: true },
29+
effectiveAt: "2026-01-01T00:00:00Z",
30+
};
31+
32+
const a = buildSemanticEnrichmentProvenance(params);
33+
const b = buildSemanticEnrichmentProvenance(params);
34+
35+
expect(a.merkleRoot).toBe(b.merkleRoot);
36+
expect(a.merkleRoot).toMatch(/^[0-9a-f]{64}$/);
37+
});
38+
39+
it("produces different Merkle roots when the parameter footprint changes", () => {
40+
const baseParams = {
41+
sourceSnapshotHash: computeSourceSnapshotHash(makeEvent({ after: "Data showed a reduction." })),
42+
parameterFootprint: { depth: "brief", profileHedges: true },
43+
effectiveAt: "2026-01-01T00:00:00Z",
44+
};
45+
46+
const unchanged = buildSemanticEnrichmentProvenance(baseParams);
47+
const changed = buildSemanticEnrichmentProvenance({
48+
...baseParams,
49+
parameterFootprint: { ...baseParams.parameterFootprint, depth: "full" },
50+
});
51+
52+
expect(unchanged.merkleRoot).not.toBe(changed.merkleRoot);
53+
});
54+
55+
it("produces different Merkle roots when the source snapshot changes", () => {
56+
const eventA = makeEvent({ after: "The trial was conclusive." });
57+
const eventB = makeEvent({ after: "The trial was inconclusive." });
58+
59+
const params = {
60+
parameterFootprint: { depth: "brief" },
61+
effectiveAt: "2026-01-01T00:00:00Z",
62+
};
63+
64+
const a = buildSemanticEnrichmentProvenance({
65+
sourceSnapshotHash: computeSourceSnapshotHash(eventA),
66+
...params,
67+
});
68+
const b = buildSemanticEnrichmentProvenance({
69+
sourceSnapshotHash: computeSourceSnapshotHash(eventB),
70+
...params,
71+
});
72+
73+
expect(a.merkleRoot).not.toBe(b.merkleRoot);
74+
});
75+
76+
it("returns all required FactProvenance fields", () => {
77+
const provenance = buildSemanticEnrichmentProvenance({
78+
sourceSnapshotHash: computeSourceSnapshotHash(makeEvent()),
79+
parameterFootprint: { depth: "brief" },
80+
effectiveAt: "2026-01-01T00:00:00Z",
81+
});
82+
83+
expect(provenance.merkleRoot).toMatch(/^[0-9a-f]{64}$/);
84+
expect(provenance.sourceSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
85+
expect(provenance.parameterFootprint).toEqual({ depth: "brief" });
86+
expect(provenance.effectiveAt).toBe("2026-01-01T00:00:00Z");
87+
expect(provenance.analyzer).toBe("@refract-org/analyzers/semantic-enrichment");
88+
expect(provenance.inputHashes).toEqual([provenance.sourceSnapshotHash]);
89+
});
90+
});
91+
92+
describe("enrichEvidenceEvent", () => {
93+
it("computes the six enrichment fields", () => {
94+
const event = makeEvent({ after: "The study demonstrated robust effects (p < 0.05)." });
95+
const result = enrichEvidenceEvent(event);
96+
97+
expect(result.editMagnitude).toBe("minor");
98+
expect(result.contentChange).toBe("introduction");
99+
expect(result.keyTerms).toContain("robust");
100+
expect(result.certaintyProfile.high).toBeGreaterThan(0);
101+
expect(result.quantitativeFindings.length).toBeGreaterThan(0);
102+
});
103+
104+
it("attaches a FactProvenance block keyed to source snapshot and parameters", () => {
105+
const event = makeEvent({ after: "The study demonstrated robust effects." });
106+
const parameters = { depth: "brief" };
107+
const result = enrichEvidenceEvent(event, { parameters });
108+
109+
expect(result.provenance.merkleRoot).toMatch(/^[0-9a-f]{64}$/);
110+
expect(result.provenance.sourceSnapshotHash).toBe(computeSourceSnapshotHash(event));
111+
expect(result.provenance.parameterFootprint).toEqual(parameters);
112+
expect(result.provenance.effectiveAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
113+
});
114+
});

packages/analyzers/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,15 @@ export { protectionTracker } from "./protection-tracker.js";
7171
export { revertDetector } from "./revert-detector.js";
7272
export type { SectionEvent, SectionLineage } from "./section-differ.js";
7373
export { buildSectionLineage, sectionDiffer } from "./section-differ.js";
74+
export type { SemanticEnrichmentResult } from "./semantic-enrichment.js";
7475
export {
76+
buildSemanticEnrichmentProvenance,
7577
computeCertaintyProfile,
7678
computeContentChange,
7779
computeDirectionSignal,
7880
computeEditMagnitude,
81+
computeSourceSnapshotHash,
82+
enrichEvidenceEvent,
7983
extractKeyTerms,
8084
extractQuantitativeFindings,
8185
} from "./semantic-enrichment.js";
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createHash } from "node:crypto";
2+
3+
/** Compute a SHA-256 leaf hash for a UTF-8 string. */
4+
export function hashLeaf(input: string): string {
5+
return createHash("sha256").update(input, "utf8").digest("hex");
6+
}
7+
8+
/** Compute a deterministic pair hash (commutative ordering). */
9+
function hashPair(a: string, b: string): string {
10+
// Sort pair so hashPair(a,b) === hashPair(b,a).
11+
const combined = a < b ? `${a}${b}` : `${b}${a}`;
12+
return createHash("sha256").update(combined, "utf8").digest("hex");
13+
}
14+
15+
/**
16+
* Build a SHA-256 Merkle root from an array of leaf hashes.
17+
* An empty array yields the hash of an empty leaf.
18+
* Odd-length levels promote the last hash unchanged.
19+
*/
20+
export function buildMerkleRoot(leaves: string[]): string {
21+
if (leaves.length === 0) return hashLeaf("");
22+
23+
let current = [...leaves];
24+
while (current.length > 1) {
25+
const next: string[] = [];
26+
for (let i = 0; i < current.length; i += 2) {
27+
if (i + 1 < current.length) {
28+
next.push(hashPair(current[i], current[i + 1]));
29+
} else {
30+
next.push(current[i]);
31+
}
32+
}
33+
current = next;
34+
}
35+
36+
return current[0] ?? "";
37+
}

packages/analyzers/src/semantic-enrichment.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
// Semantic enrichment — deterministic text analysis for evidence events
22
// No model. No API. Byte-reproducible on every run.
33

4-
import type { CertaintyProfile, DirectionSignal, QuantitativeFinding } from "@refract-org/evidence-graph";
4+
import {
5+
type CertaintyProfile,
6+
type ContentChange,
7+
type DirectionSignal,
8+
type EditMagnitude,
9+
EVENT_SCHEMA_VERSION,
10+
type EvidenceEvent,
11+
type FactProvenance,
12+
type QuantitativeFinding,
13+
} from "@refract-org/evidence-graph";
14+
import { buildMerkleRoot, hashLeaf } from "./merkle-tree.js";
515

616
const CERTAINTY_PATTERNS = {
717
high: [/demonstrat\w*/i, /prove\w*/i, /confirm\w*/i, /establish\w*/i, /significantly/i, /robust/i, /definitive/i],
@@ -106,3 +116,133 @@ export function extractKeyTerms(text: string): string[] {
106116
}
107117
return [...terms].slice(0, 15); // Limit to top 15
108118
}
119+
120+
/**
121+
* Deterministic JSON serialization for hash inputs.
122+
* Sorts object keys recursively so equivalent values always serialize
123+
* to the same byte string.
124+
*/
125+
function stableStringify(value: unknown): string {
126+
if (value === undefined) return "undefined";
127+
if (value === null) return "null";
128+
129+
switch (typeof value) {
130+
case "boolean":
131+
case "number":
132+
return String(value);
133+
case "string":
134+
return JSON.stringify(value);
135+
case "object": {
136+
if (Array.isArray(value)) {
137+
return `[${value.map(stableStringify).join(",")}]`;
138+
}
139+
const record = value as Record<string, unknown>;
140+
const keys = Object.keys(record).sort();
141+
const pairs = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`);
142+
return `{${pairs.join(",")}}`;
143+
}
144+
default:
145+
return "null";
146+
}
147+
}
148+
149+
function parameterFootprintToLegacyParameters(
150+
footprint: Record<string, unknown>,
151+
): Record<string, string | number | boolean> | undefined {
152+
const out: Record<string, string | number | boolean> = {};
153+
let hasAny = false;
154+
for (const [key, value] of Object.entries(footprint)) {
155+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
156+
out[key] = value;
157+
hasAny = true;
158+
}
159+
}
160+
return hasAny ? out : undefined;
161+
}
162+
163+
/** The result of applying semantic enrichment to a single evidence event. */
164+
export interface SemanticEnrichmentResult {
165+
editMagnitude: EditMagnitude;
166+
contentChange: ContentChange;
167+
keyTerms: string[];
168+
certaintyProfile: CertaintyProfile;
169+
directionSignal: DirectionSignal;
170+
quantitativeFindings: QuantitativeFinding[];
171+
/** Strictly typed provenance block carrying the Merkle root of source + parameters. */
172+
provenance: FactProvenance;
173+
}
174+
175+
/** Build a deterministic source snapshot hash from the raw event state. */
176+
export function computeSourceSnapshotHash(event: EvidenceEvent): string {
177+
const snapshot = `${event.fromRevisionId}|${event.toRevisionId}|${event.section}|${event.before}|${event.after}`;
178+
return hashLeaf(snapshot);
179+
}
180+
181+
/** Build a FactProvenance block for semantic enrichment output. */
182+
export function buildSemanticEnrichmentProvenance(params: {
183+
sourceSnapshotHash: string;
184+
parameterFootprint: Record<string, unknown>;
185+
analyzer?: string;
186+
version?: string;
187+
inputHashes?: string[];
188+
effectiveAt?: string;
189+
}): FactProvenance {
190+
const parameterHash = hashLeaf(stableStringify(params.parameterFootprint));
191+
const merkleRoot = buildMerkleRoot([params.sourceSnapshotHash, parameterHash]);
192+
return {
193+
analyzer: params.analyzer ?? "@refract-org/analyzers/semantic-enrichment",
194+
version: params.version ?? EVENT_SCHEMA_VERSION,
195+
inputHashes: params.inputHashes ?? [params.sourceSnapshotHash],
196+
parameters: parameterFootprintToLegacyParameters(params.parameterFootprint),
197+
parameterFootprint: params.parameterFootprint,
198+
sourceSnapshotHash: params.sourceSnapshotHash,
199+
effectiveAt: params.effectiveAt ?? new Date().toISOString(),
200+
merkleRoot,
201+
};
202+
}
203+
204+
/**
205+
* Run the full deterministic semantic enrichment pipeline for an evidence event
206+
* and emit a strictly typed FactProvenance block alongside the enrichment fields.
207+
*/
208+
export function enrichEvidenceEvent(
209+
event: EvidenceEvent,
210+
options?: {
211+
analyzer?: string;
212+
version?: string;
213+
parameters?: Record<string, unknown>;
214+
effectiveAt?: string;
215+
},
216+
): SemanticEnrichmentResult {
217+
const before = event.before || "";
218+
const after = event.after || "";
219+
const text = after || before;
220+
221+
const editMagnitude = computeEditMagnitude(before.length, after.length);
222+
const contentChange = computeContentChange(event.eventType, before, after);
223+
const keyTerms = extractKeyTerms(text);
224+
const certaintyProfile = computeCertaintyProfile(text);
225+
const directionSignal = computeDirectionSignal(computeCertaintyProfile(before), computeCertaintyProfile(after));
226+
const quantitativeFindings = extractQuantitativeFindings(text);
227+
228+
const sourceSnapshotHash = computeSourceSnapshotHash(event);
229+
const parameterFootprint = options?.parameters ?? {};
230+
231+
const provenance = buildSemanticEnrichmentProvenance({
232+
sourceSnapshotHash,
233+
parameterFootprint,
234+
analyzer: options?.analyzer,
235+
version: options?.version,
236+
effectiveAt: options?.effectiveAt,
237+
});
238+
239+
return {
240+
editMagnitude,
241+
contentChange,
242+
keyTerms,
243+
certaintyProfile,
244+
directionSignal,
245+
quantitativeFindings,
246+
provenance,
247+
};
248+
}

0 commit comments

Comments
 (0)