slim the submit schemas: evidence and messages die; stringified-payload heal at the pre-validation seam
OpenRouter log diagnosis (owner, 2026-09-28): extraction time was
dominated by tool-validation retries. The failing call had the whole
propositions array emitted as ONE JSON-STRING ('propositions.0: must
be object'), a truncated placeholder artifact inside it, and
word-salad statements late in the array — payload-size collapse.
Field audit: 'evidence' was never persisted anywhere and its
faithfulness-checking consumer died with the fully-trusted ruling;
'messages' fed messageId granularity that NOTHING downstream reads,
and the model sent [] anyway (chat-level provenance in practice).
'aliases' stays (optional now): the auditor's find tool globs them.
Schema: propositions = {id, name, statement, aliases?}; edges =
{from, to, type}. Prompts lose the evidence/messages doctrine and
[id]-anchor guidance, gain a compact faithfulness line;
transcriptView renders anchorless. The auditor echo instruction
drops the evidence/messages mention, so its payload halves too.
Heal: healStringifiedArrays on the submit tool's prepareArguments
(pi-agent-core's sanctioned pre-TypeBox shim) parses a stringified
propositions/edges back into arrays — the LaTeX-restore doctrine:
heal weak-model wire behavior at the seam. Test drives the real
agent-loop seam with a stringified submit.
This commit is contained in:
+38
-25
@@ -221,7 +221,7 @@ after(() => {
|
||||
});
|
||||
|
||||
/** What the extractor would produce for the fixture session — tree-blind,
|
||||
* free-form, with message-granular provenance. */
|
||||
* free-form, with chat-level provenance. */
|
||||
const FINDINGS: SessionFindings = {
|
||||
propositions: [
|
||||
{
|
||||
@@ -229,16 +229,12 @@ const FINDINGS: SessionFindings = {
|
||||
name: "Derivative",
|
||||
statement: "The derivative measures how fast a function changes",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["The derivative measures how fast a function changes."],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
name: "Chain rule",
|
||||
statement: "The chain rule differentiates composed functions by multiplying local rates.",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["teach me the chain rule"],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -246,8 +242,6 @@ const FINDINGS: SessionFindings = {
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
type: "follows",
|
||||
messages: ["a2"],
|
||||
evidence: ["teach me the chain rule"],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -322,20 +316,21 @@ function silentModel(): PiEngine {
|
||||
// ─── transcriptView (pure) ───────────────────────────────────────────────────
|
||||
|
||||
describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27)", () => {
|
||||
it("renders id-anchored speaker blocks + check blocks — tree-blind, plan ops folded", () => {
|
||||
it("renders anchorless speaker blocks + check blocks — tree-blind, plan ops folded", () => {
|
||||
const { record } = fixtureChat();
|
||||
const view = transcriptView(record);
|
||||
|
||||
// The document header is the session title.
|
||||
assert.match(view, /^# Extraction fixture$/m);
|
||||
|
||||
// Conversation verbatim, each block headed by its speaker + the
|
||||
// message-id anchor — the provenance the extractor reports back.
|
||||
assert.match(view, /### Learner · u1\n\nteach me the chain rule/);
|
||||
assert.match(view, /### Teacher · a2\n\nThe derivative measures how fast a function changes\./);
|
||||
// Conversation verbatim, each block headed by its speaker only —
|
||||
// the [id] anchors are GONE (submit-schema slim, 2026-09-28:
|
||||
// propositions no longer report message ids).
|
||||
assert.match(view, /### Learner\n\nteach me the chain rule/);
|
||||
assert.match(view, /### Teacher\n\nThe derivative measures how fast a function changes\./);
|
||||
|
||||
// Checks render as readable blocks (question + outcome).
|
||||
assert.match(view, /### Quiz · r_probe-1/);
|
||||
assert.match(view, /### Quiz\n/);
|
||||
assert.match(view, /\*\*Q:\*\* What is a derivative, roughly\?/);
|
||||
assert.match(view, /The learner answered incorrectly\./);
|
||||
assert.match(view, /The learner answered correctly\./);
|
||||
@@ -373,6 +368,25 @@ describe("transcriptView (pure — the shared markdown engine, owner 2026-09-27)
|
||||
// ─── the pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
it("stringified-payload heal (owner, 2026-09-28): propositions arriving as ONE JSON string still validate and merge", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
const verdict: AuditResult = { propositions: FINDINGS.propositions, edges: FINDINGS.edges };
|
||||
|
||||
// The weak-model failure from the OpenRouter log: the whole array
|
||||
// emitted as a JSON-STRING ("propositions.0: must be object").
|
||||
// pi-agent-core's prepareArguments shim runs BEFORE TypeBox — the
|
||||
// heal parses it, validation passes, no retry burned.
|
||||
const extractor = submitModel("submit_findings", {
|
||||
propositions: JSON.stringify(FINDINGS.propositions),
|
||||
edges: FINDINGS.edges,
|
||||
});
|
||||
|
||||
const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
|
||||
const stats = await pipeline.runExtraction(chatId);
|
||||
assert.deepEqual(stats, { nodes: 2, edges: 1 });
|
||||
});
|
||||
|
||||
it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
@@ -631,7 +645,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const ledgers = memoryLedgers();
|
||||
|
||||
const single = (id: string, name: string, statement: string): SessionFindings => ({
|
||||
propositions: [{ id, name, statement, aliases: [], messages: ["a2"], evidence: ["e"] }],
|
||||
propositions: [{ id, name, statement, aliases: [], }],
|
||||
edges: [],
|
||||
});
|
||||
const findingsA = single("pa", "Alpha claim", "Alpha statement, full precision");
|
||||
@@ -688,7 +702,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const ledgers = memoryLedgers();
|
||||
|
||||
const single = (id: string, name: string, statement: string): SessionFindings => ({
|
||||
propositions: [{ id, name, statement, aliases: [], messages: ["a2"], evidence: ["e"] }],
|
||||
propositions: [{ id, name, statement, aliases: [], }],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
@@ -938,8 +952,6 @@ const NOTES_FINDINGS = {
|
||||
name: "Vim normal mode",
|
||||
statement: "Vim starts in Normal mode, where keys are commands.",
|
||||
aliases: [],
|
||||
messages: [],
|
||||
evidence: ["Vim always starts in Normal mode — keys are commands, not text."],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -980,11 +992,12 @@ describe("the notes front door (importNotes — format-agnostic)", () => {
|
||||
assert.equal(source.extractions![0]!.stats?.nodes, 1);
|
||||
assert.ok(source.extractions![0]!.stages.extractor.steps.length > 0, "faux usage captured");
|
||||
|
||||
// File-scoped provenance: the mapping line carries the source id;
|
||||
// the node carries NO message anchors.
|
||||
// File-scoped provenance (submit-schema slim, 2026-09-28): the
|
||||
// node carries ONE chat-level point — the source key itself, no
|
||||
// message anchors.
|
||||
const node = ledgers.readNodes()[0]!;
|
||||
assert.equal(node.statement, "Vim starts in Normal mode, where keys are commands.");
|
||||
assert.deepEqual(node.provenance, []);
|
||||
assert.deepEqual(node.provenance, [{ chatId: source.id, messageId: "" }]);
|
||||
assert.deepEqual(ledgers.readMappings(), [{ chatId: source.id, sourceId: "p1", graphNodeId: node.id }]);
|
||||
// The checkpoint stamps the import.
|
||||
assert.equal(ledgers.readCheckpoints()[0]!.chatId, source.id);
|
||||
@@ -1102,10 +1115,10 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
// Two batches: the first held messages 1-3, the second 4-5 — a
|
||||
// message is never split, batches are consecutive oldest-first.
|
||||
assert.equal(views.length, 2, "one extractor call per batch");
|
||||
assert.ok(views[0]!.includes("### Learner · u1") && views[0]!.includes("### Learner · u3"));
|
||||
assert.ok(!views[0]!.includes("### Learner · u4"), "the batch stops at the token budget");
|
||||
assert.ok(views[1]!.includes("### Learner · u4") && views[1]!.includes("### Learner · u5"));
|
||||
assert.ok(!views[1]!.includes("### Learner · u3"));
|
||||
assert.ok(views[0]!.includes("### Learner") && views[0]!.includes("topic 1:"));
|
||||
assert.ok(!views[0]!.includes("topic 4:"), "the batch stops at the token budget");
|
||||
assert.ok(views[1]!.includes("### Learner") && views[1]!.includes("topic 4:"));
|
||||
assert.ok(!views[1]!.includes("topic 3:"));
|
||||
// The document header is the session title.
|
||||
assert.ok(views[0]!.startsWith("# Chunky coverage fixture"));
|
||||
|
||||
@@ -1184,7 +1197,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
});
|
||||
await finisher.runExtraction(chatId);
|
||||
assert.equal(reRunViews.length, 1);
|
||||
assert.ok(reRunViews[0]!.includes("### Learner · u4") && reRunViews[0]!.includes("### Learner · u5"));
|
||||
assert.ok(reRunViews[0]!.includes("### Learner") && reRunViews[0]!.includes("topic 4:"));
|
||||
assert.ok(!reRunViews[0]!.includes("### Learner · u1"), "the covered remainder never re-enters the prompt");
|
||||
assert.equal(ledgers.readCoverage().length, 5, "the re-run closed the gap");
|
||||
});
|
||||
|
||||
+22
-41
@@ -224,24 +224,18 @@ function fixtureAudit(): AuditResult {
|
||||
name: "Alpha",
|
||||
statement: "Alpha intuition",
|
||||
aliases: ["alpha feel"],
|
||||
messages: ["a1", "a3"],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
name: "Beta rule",
|
||||
statement: "Beta rule",
|
||||
aliases: [],
|
||||
messages: ["a4"],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
name: "Gamma",
|
||||
statement: "Gamma application",
|
||||
aliases: [],
|
||||
messages: ["a5"],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -249,8 +243,6 @@ function fixtureAudit(): AuditResult {
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
type: "follows",
|
||||
messages: ["a4"],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -282,7 +274,7 @@ function scopedGraph(chatIds: string[]) {
|
||||
}
|
||||
|
||||
describe("the merger (mergeFindings)", () => {
|
||||
it("creates nodes from the verdicts with message-granular provenance", () => {
|
||||
it("creates nodes from the verdicts with chat-level provenance", () => {
|
||||
const stats = mergeFindings(chat1.id, fixtureAudit(), graphDeps, sessionSource(chat1.id));
|
||||
assert.deepEqual(stats, { nodes: 3, edges: 1 });
|
||||
|
||||
@@ -293,14 +285,13 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.match(node.id, /^n_/);
|
||||
assert.ok(node.name.length > 0, "name is required");
|
||||
assert.equal("kind" in node, false, "no kind field");
|
||||
assert.ok(Array.isArray(node.provenance), "message-granular provenance");
|
||||
assert.ok(Array.isArray(node.provenance), "provenance attached");
|
||||
}
|
||||
// Provenance is message-granular: p1 came up in a1 and a3.
|
||||
// Provenance is CHAT-LEVEL (submit-schema slim, 2026-09-28):
|
||||
// propositions no longer carry message ids — the run's source key
|
||||
// is the whole provenance.
|
||||
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
|
||||
assert.deepEqual(alpha.provenance, [
|
||||
{ chatId: chat1.id, messageId: "a1" },
|
||||
{ chatId: chat1.id, messageId: "a3" },
|
||||
]);
|
||||
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||
|
||||
// The follows edge, provenance attached.
|
||||
assert.equal(graph.edges.length, 1);
|
||||
@@ -308,7 +299,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.equal(edge.from, idByStatement.get("Alpha intuition"));
|
||||
assert.equal(edge.to, idByStatement.get("Beta rule"));
|
||||
assert.equal(edge.type, "follows");
|
||||
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "a4" }]);
|
||||
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
|
||||
|
||||
// Mapping lines: one per (chat, sourceId).
|
||||
assert.deepEqual(
|
||||
@@ -351,8 +342,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
name: "Alpha",
|
||||
statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings
|
||||
aliases: ["alpha intuition"],
|
||||
messages: ["a1"],
|
||||
evidence: ["fixture evidence"],
|
||||
linkTo: alphaId,
|
||||
},
|
||||
],
|
||||
@@ -369,7 +358,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.ok(alpha.aliases.includes("Alpha feel"), "the session's phrasing joins the aliases");
|
||||
assert.ok(alpha.aliases.includes("alpha intuition"));
|
||||
assert.ok(
|
||||
alpha.provenance.some((pt) => pt.chatId === chat2.id && pt.messageId === "a1"),
|
||||
alpha.provenance.some((pt) => pt.chatId === chat2.id),
|
||||
"provenance unions",
|
||||
);
|
||||
// The mapping records the verdict: (chat2, q1) → the linked node.
|
||||
@@ -391,8 +380,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
name: "Vim registers",
|
||||
statement: "Vim registers map single keys to editing commands.",
|
||||
aliases: [],
|
||||
messages: [],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -421,14 +408,12 @@ describe("the merger (mergeFindings)", () => {
|
||||
id: `p${i + 1}`,
|
||||
name: `Cluster node ${i + 1}`,
|
||||
statement,
|
||||
aliases: [],
|
||||
messages: [`cluster_msg_${i + 1}`], // birth provenance — the message anchors
|
||||
evidence: ["fixture evidence"],
|
||||
aliases: [], // birth provenance — the message anchors
|
||||
})),
|
||||
edges: [
|
||||
{ from: "p1", to: "p2", type: "follows", messages: [], evidence: ["e"] },
|
||||
{ from: "p2", to: "p3", type: "follows", messages: [], evidence: ["e"] },
|
||||
{ from: "p1", to: "p3", type: "follows", messages: [], evidence: ["e"] },
|
||||
{ from: "p1", to: "p2", type: "follows", },
|
||||
{ from: "p2", to: "p3", type: "follows", },
|
||||
{ from: "p1", to: "p3", type: "follows", },
|
||||
],
|
||||
},
|
||||
graphDeps,
|
||||
@@ -477,8 +462,6 @@ describe("the merger (mergeFindings)", () => {
|
||||
name: "Alpha",
|
||||
statement: "Alpha intuition", // IDENTICAL to the existing node's statement
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["fixture evidence"],
|
||||
// no linkTo — identity is the auditor's call alone
|
||||
},
|
||||
],
|
||||
@@ -502,9 +485,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
id: "p1",
|
||||
name: "Vim normal mode",
|
||||
statement: "Vim starts in Normal mode, where keys are commands.",
|
||||
aliases: [],
|
||||
messages: [], // notes carry no message anchors — the file is the provenance
|
||||
evidence: ["fixture evidence"],
|
||||
aliases: [], // notes carry no message anchors — the file is the provenance
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -515,7 +496,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0 });
|
||||
|
||||
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
|
||||
assert.deepEqual(node.provenance, [], "no message anchors — the mapping line carries the source");
|
||||
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
|
||||
assert.deepEqual(graphStore.readMappings().find((m) => m.chatId === "src_notesfixture"), {
|
||||
chatId: "src_notesfixture",
|
||||
sourceId: "p1",
|
||||
@@ -607,10 +588,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
].flat());
|
||||
const auditA: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"] },
|
||||
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], messages: ["du1"], evidence: ["e"] },
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
|
||||
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], },
|
||||
],
|
||||
edges: [{ from: "p1", to: "p2", type: "follows", messages: ["du1"], evidence: ["e"] }],
|
||||
edges: [{ from: "p1", to: "p2", type: "follows", }],
|
||||
};
|
||||
mergeFindings(dA.id, auditA, graphDeps, sessionSource(dA.id));
|
||||
// Resolve through THIS session's mappings — the shared ledger holds
|
||||
@@ -633,10 +614,10 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
].flat());
|
||||
const auditB: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId },
|
||||
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], messages: ["eu1"], evidence: ["e"] },
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], linkTo: xId },
|
||||
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], },
|
||||
],
|
||||
edges: [{ from: "q1", to: "q2", type: "follows", messages: ["eu1"], evidence: ["e"] }],
|
||||
edges: [{ from: "q1", to: "q2", type: "follows", }],
|
||||
};
|
||||
mergeFindings(dB.id, auditB, graphDeps, sessionSource(dB.id));
|
||||
const zId = graphStore.readMappings().find((m) => m.chatId === dB.id && m.sourceId === "q2")!.graphNodeId;
|
||||
@@ -673,7 +654,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.equal(x.name, "Shared core");
|
||||
assert.ok(!x.aliases.includes("shared feel"), "the deleted creator's phrasings are stripped (owner ruling)");
|
||||
assert.ok(x.aliases.includes("B phrasing") && x.aliases.includes("Shared core, rephrased"));
|
||||
assert.deepEqual(x.provenance, [{ chatId: dB.id, messageId: "eu1" }], "only dB's provenance remains");
|
||||
assert.deepEqual(x.provenance, [{ chatId: dB.id, messageId: "" }], "only dB's provenance remains");
|
||||
// Y (the orphan) is gone entirely.
|
||||
assert.equal(graph.nodes.some((n) => n.id === yId), false);
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
|
||||
@@ -681,7 +662,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore);
|
||||
// Edges: dA's assertion X→Y died; dB's X→Z survived.
|
||||
const scoped = scopedGraph([dA.id, dB.id]);
|
||||
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "eu1" }] }]);
|
||||
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]);
|
||||
// Mappings: dA's audit trail died; dB's stays.
|
||||
const mappings = graphStore.readMappings().filter((m) => [dA.id, dB.id].includes(m.chatId));
|
||||
assert.deepEqual(mappings, [{ chatId: dB.id, sourceId: "q1", graphNodeId: xId }, { chatId: dB.id, sourceId: "q2", graphNodeId: zId }]);
|
||||
|
||||
Reference in New Issue
Block a user