2026-08-22 10:30:00 +03:00
/**
2026-08-31 22:36:41 +03:00
* Graph merger + trigger + derivation tests (vision §1; contracts.ts
* "extraction pipeline"). The merger (core/graph.ts mergeFindings) is the
* pipeline's deterministic apply stage: findings in, ledger appends out —
* identity resolution, retroactive probe attribution, event rebuild, and
* the idempotency guarantees.
2026-08-22 10:30:00 +03:00
*
* Ledger isolation: PATHS is fixed at import time, so the five data/graph
* ledgers are backed up and cleared before the suite and restored after
* (try/finally-shaped: the after() hook always runs, even on failure).
* The chat fixtures are real ChatRecords in the chat store (created and
2026-08-31 22:36:41 +03:00
* deleted by this suite) — the merger reads exactly what production reads.
* The full LLM pipeline (extractor → auditor) is tested in
* tests/extraction.test.ts with mock models.
2026-08-22 10:30:00 +03:00
*/
import assert from "node:assert/strict" ;
import * as fs from "node:fs" ;
import { after , describe , it } from "node:test" ;
import type { UIMessage } from "ai" ;
2026-08-31 22:36:41 +03:00
import type { PlanTree , PlanToolResult , SessionFindings } from "../src/core/contracts.ts" ;
2026-08-22 10:30:00 +03:00
import type { QuestionAnswerData } from "../src/core/question-core.ts" ;
2026-08-31 22:36:41 +03:00
import { createTree , markDone } from "../src/core/plan-tree.ts" ;
import { getGraph , graphSnapshot , mergeFindings , pendingQuizAnswers , type GraphDeps } from "../src/core/graph.ts" ;
2026-08-31 20:00:12 +03:00
import { graphStore } from "../src/store/graph.ts" ;
import { chatStore } from "../src/store/chats.ts" ;
2026-08-22 10:30:00 +03:00
import { LEDGER_FILES , PATHS } from "../config.ts" ;
2026-08-31 20:00:12 +03:00
/** The wired fs adapters, exactly what src/server/stores.ts passes in production. */
const graphDeps = { chatStore , graphStore } as const satisfies GraphDeps ;
2026-08-22 10:30:00 +03:00
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
const LEDGER_PATHS = Object . values ( LEDGER_FILES );
const backups : Array < { file : string ; bak : string } > = [];
const createdChats : string [] = [];
fs . mkdirSync ( PATHS . graph , { recursive : true });
for ( const file of LEDGER_PATHS ) {
if ( fs . existsSync ( file )) {
2026-08-31 22:36:41 +03:00
const bak = ` ${ file } .w5test.bak` ;
2026-08-22 10:30:00 +03:00
fs . renameSync ( file , bak );
backups . push ({ file , bak });
}
}
after (() => {
for ( const file of LEDGER_PATHS ) {
try {
fs . unlinkSync ( file );
} catch {
/* nothing was written here */
}
}
for ( const { file , bak } of backups ) fs . renameSync ( bak , file );
for ( const id of createdChats ) {
try {
fs . unlinkSync ( ` ${ PATHS . chats } / ${ id } .json` );
} catch {
/* already gone */
}
}
});
// ─── fixture builders ────────────────────────────────────────────────────────
function assistant ( id : string , parts : unknown []) : UIMessage {
return { id , role : "assistant" , parts } as unknown as UIMessage ;
}
function user ( id : string , text : string ) : UIMessage {
return { id , role : "user" , parts : [{ type : "text" , text }] } as unknown as UIMessage ;
}
function quizPart (
toolCallId : string ,
input : Record < string , unknown >,
output : QuestionAnswerData ,
) : unknown {
return { type : "tool-quiz" , toolCallId , state : "output-available" , input , output };
}
function planPart ( toolCallId : string , name : string , input : Record < string , unknown >, tree : PlanTree ) : unknown {
return {
type : `tool- ${ name } ` ,
toolCallId ,
state : "output-available" ,
input ,
output : { ok : true , tree } satisfies PlanToolResult ,
};
}
function answer ( over : Partial < QuestionAnswerData >) : QuestionAnswerData {
return {
questionId : "q" ,
kind : "quiz" ,
questionText : "question text" ,
dontKnow : false ,
answers : [],
answeredAt : "2026-08-22T00:00:00.000Z" ,
... over ,
};
}
/** Create a chat in the real store with a tree and messages; returns the record. */
function fixtureChat ( title : string , tree : PlanTree | null , messages : UIMessage []) {
2026-08-31 20:00:12 +03:00
const chat = chatStore . createChat ( title );
2026-08-22 10:30:00 +03:00
createdChats . push ( chat . id );
2026-08-31 20:00:12 +03:00
if ( tree ) chatStore . saveTree ( chat . id , tree );
chatStore . saveMessages ( chat . id , messages );
return chatStore . getChat ( chat . id ) ! ;
2026-08-22 10:30:00 +03:00
}
/** The fixture tree: alpha → beta, gamma free-floating, alpha done. */
function fixtureTree () : PlanTree {
return markDone (
createTree (
[
{ id : "alpha" , title : "Alpha intuition" },
{ id : "beta" , title : "Beta rule" },
{ id : "gamma" , title : "Gamma application" },
],
[{ from : "alpha" , to : "beta" }],
),
"alpha" ,
);
}
2026-08-31 22:36:41 +03:00
/**
* Findings shaped exactly like what the extractor would produce for a
* session's plan tree: one proposition per tree node (statement = title,
* treeNodeId set), one `requires` edge per tree edge.
*/
function treeFindings ( tree : PlanTree ) : SessionFindings {
const idByNodeId = new Map ( tree . nodes . map (( n , i ) => [ n . id , `p ${ i + 1 } ` ]));
return {
propositions : tree.nodes.map (( n , i ) => ({
id : `p ${ i + 1 } ` ,
2026-09-01 03:34:47 +03:00
name : n.title ,
2026-08-31 22:36:41 +03:00
statement : n.title ,
aliases : [],
kind : "truth" as const ,
treeNodeId : n.id ,
evidence : [ "fixture evidence" ],
})),
edges : tree.edges.map (( e ) => ({
from : idByNodeId . get ( e . from ) ! ,
to : idByNodeId.get ( e . to ) ! ,
type : "requires" as const ,
evidence : [ "fixture evidence" ],
})),
attribution : { quiz : {} },
};
}
2026-08-22 10:30:00 +03:00
/** The full fixture transcript: probes → plan → checks → mark_done → noise. */
function fixtureMessages ( tree : PlanTree ) : UIMessage [] {
return [
user ( "u1" , "teach me the fixture topic" ),
assistant ( "a1" , [
quizPart (
"call-1" ,
{ question : "Q1" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-1" , questionText : "Q1 text" , correct : false , answeredAt : "2026-08-22T01:00:00.000Z" }),
),
]),
assistant ( "a2" , [
quizPart (
"call-2" ,
{ question : "Q2" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-2" , questionText : "Q2 text" , dontKnow : true , correct : false , answeredAt : "2026-08-22T01:01:00.000Z" }),
),
]),
assistant ( "a3" , [ planPart ( "p1" , "plan_init" , { nodes : [], edges : [] }, tree )]),
assistant ( "a4" , [
quizPart (
"call-3" ,
{ question : "Q3" , nodeId : "alpha" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-3" , questionText : "Q3 text" , correct : true , note : "recalled the diagram" , answeredAt : "2026-08-22T02:00:00.000Z" }),
),
]),
assistant ( "a5" , [
quizPart (
"call-4" ,
{ question : "Q4" , nodeId : "beta" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-4" , questionText : "Q4 text" , correct : false , answeredAt : "2026-08-22T02:01:00.000Z" }),
),
]),
assistant ( "a6" , [
2026-08-31 22:36:41 +03:00
// nodeId present but NOT in the tree → tagged-but-unmapped: the
// attribution can rescue it, or it lands null + question text.
2026-08-22 10:30:00 +03:00
quizPart (
"call-5" ,
{ question : "Q5" , nodeId : "ghost" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-5" , questionText : "Q5 text" , dontKnow : true , correct : false , answeredAt : "2026-08-22T02:02:00.000Z" }),
),
]),
assistant ( "a7" , [ planPart ( "p2" , "plan_mark_done" , { id : "alpha" }, tree )]),
assistant ( "a8" , [
2026-08-31 22:36:41 +03:00
// dismissed quiz + an answered ask — neither becomes an event
2026-08-22 10:30:00 +03:00
quizPart (
"call-6" ,
{ question : "Q6" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-6" , questionText : "Q6 text" , dismissed : true }),
),
{
type : "tool-ask_user_question" ,
toolCallId : "call-7" ,
state : "output-available" ,
input : { question : "free text?" },
output : answer ({ questionId : "call-7" , kind : "ask" , questionText : "free text?" , text : "because" }),
},
]),
];
}
2026-08-31 22:36:41 +03:00
/**
* The extractor's findings for the fixture chat: the tree as propositions +
* the probe attribution. The audit stage is a no-op pass-through in these
* merger tests — its behavior is tested with the pipeline (extraction.test).
*/
function fixtureFindings () : SessionFindings {
const findings = treeFindings ( fixtureTree ());
// Untagged probe quizzes: call-1 attributed retroactively to Alpha (the
// vision's open question — resolved by the extractor); call-2 genuinely
// unattributable; call-5's ghost nodeId is rescued onto Gamma.
findings . attribution . quiz = { "call-1" : "p1" , "call-2" : null , "call-5" : "p3" };
return findings ;
}
/** The chat every merger test folds (shared, so ordering is intentional). */
2026-08-22 10:30:00 +03:00
const chat1 = fixtureChat ( "Graph fixture" , fixtureTree (), fixtureMessages ( fixtureTree ()));
2026-08-31 22:36:41 +03:00
/** Set by the cross-session test; the snapshot test (which runs after it) reads it. */
2026-08-22 10:30:00 +03:00
let chat2Id = "" ;
2026-08-31 22:36:41 +03:00
// ─── the merger ──────────────────────────────────────────────────────────────
2026-08-22 10:30:00 +03:00
/** Everything the graph holds for a set of chats (other processes' test
* emissions run concurrently against the same files — scope, never assume). */
function scopedGraph ( chatIds : string []) {
2026-08-31 20:00:12 +03:00
const graph = getGraph ( graphStore );
2026-08-22 10:30:00 +03:00
const nodeIds = new Set (
2026-08-31 20:00:12 +03:00
graphStore
2026-08-22 10:30:00 +03:00
. readMappings ()
. filter (( m ) => chatIds . includes ( m . chatId ))
. map (( m ) => m . graphNodeId ),
);
return {
nodes : graph.nodes.filter (( n ) => nodeIds . has ( n . id )),
edges : graph.edges.filter (( e ) => e . provenance . some (( c ) => chatIds . includes ( c ))),
events : graph.events.filter (( e ) => chatIds . includes ( e . chatId )),
};
}
2026-08-31 22:36:41 +03:00
describe ( "the merger (mergeFindings)" , () => {
it ( "merges propositions/edges by identity and rebuilds events with retroactive probe attribution" , () => {
const stats = mergeFindings ( chat1 . id , fixtureFindings (), graphDeps );
2026-08-22 10:30:00 +03:00
assert . deepEqual ( stats , { nodes : 3 , edges : 1 , events : 6 });
const graph = scopedGraph ([ chat1 . id ]);
assert . equal ( graph . nodes . length , 3 );
const idByStatement = new Map ( graph . nodes . map (( n ) => [ n . statement , n . id ]));
for ( const node of graph . nodes ) {
assert . equal ( node . kind , "truth" );
assert . deepEqual ( node . aliases , []);
assert . match ( node . id , /^n_/ );
}
2026-08-31 22:36:41 +03:00
// The tree edge alpha→beta becomes a `requires` edge on the mapped nodes.
2026-08-22 10:30:00 +03:00
assert . equal ( graph . edges . length , 1 );
const edge = graph . edges [ 0 ] ! ;
assert . equal ( edge . from , idByStatement . get ( "Alpha intuition" ));
assert . equal ( edge . to , idByStatement . get ( "Beta rule" ));
assert . equal ( edge . type , "requires" );
assert . deepEqual ( edge . provenance , [ chat1 . id ]);
2026-08-31 22:36:41 +03:00
// Events — kinds, refs, notes, and the attribution paths.
2026-08-22 10:30:00 +03:00
assert . equal ( graph . events . length , 6 );
const byRef = new Map ( graph . events . map (( e ) => [ e . ref , e ]));
const probeMiss = byRef . get ( "call-1" ) ! ;
assert . equal ( probeMiss . kind , "probe-miss" ); // quiz BEFORE plan_init
2026-08-31 22:36:41 +03:00
assert . equal ( probeMiss . nodeId , idByStatement . get ( "Alpha intuition" ), "retroactive probe attribution" );
assert . equal ( probeMiss . question , undefined , "attributed → no question text needed" );
2026-08-22 10:30:00 +03:00
const probeDontKnow = byRef . get ( "call-2" ) ! ;
assert . equal ( probeDontKnow . kind , "dont-know" ); // same kind, both phases
2026-08-31 22:36:41 +03:00
assert . equal ( probeDontKnow . nodeId , null , "unattributable probe → null" );
assert . equal ( probeDontKnow . question , "Q2 text" );
2026-08-22 10:30:00 +03:00
const check = byRef . get ( "call-3" ) ! ;
assert . equal ( check . kind , "check-correct" ); // quiz AFTER plan_init
2026-08-31 22:36:41 +03:00
assert . equal ( check . nodeId , idByStatement . get ( "Alpha intuition" ), "tagged quiz → deterministic" );
2026-08-22 10:30:00 +03:00
assert . equal ( check . note , "recalled the diagram" );
const checkMiss = byRef . get ( "call-4" ) ! ;
assert . equal ( checkMiss . kind , "check-incorrect" );
assert . equal ( checkMiss . nodeId , idByStatement . get ( "Beta rule" ));
2026-08-31 22:36:41 +03:00
const ghost = byRef . get ( "call-5" ) ! ;
assert . equal ( ghost . kind , "dont-know" );
assert . equal ( ghost . nodeId , idByStatement . get ( "Gamma application" ), "tagged-but-unmapped rescued by attribution" );
assert . equal ( ghost . question , undefined );
2026-08-22 10:30:00 +03:00
const taught = byRef . get ( "taught:alpha" ) ! ;
assert . equal ( taught . kind , "taught" );
2026-08-31 22:36:41 +03:00
assert . equal ( taught . nodeId , idByStatement . get ( "Alpha intuition" ), "mark_done → tree-node mapping" );
2026-08-22 10:30:00 +03:00
2026-08-31 22:36:41 +03:00
// The dismissed quiz and the ask question became nothing.
2026-08-22 10:30:00 +03:00
assert . equal ([... byRef . keys ()]. filter (( r ) => r === "call-6" || r === "call-7" ). length , 0 );
});
2026-08-31 22:36:41 +03:00
it ( "is idempotent — re-merging the same findings appends nothing" , () => {
const stats = mergeFindings ( chat1 . id , fixtureFindings (), graphDeps );
2026-08-22 10:30:00 +03:00
assert . deepEqual ( stats , { nodes : 0 , edges : 0 , events : 0 });
const graph = scopedGraph ([ chat1 . id ]);
assert . equal ( graph . nodes . length , 3 );
assert . equal ( graph . edges . length , 1 );
assert . equal ( graph . events . length , 6 );
2026-08-31 22:36:41 +03:00
// exactly one checkpoint line per merge, though — checkpoints are a log
2026-08-31 20:00:12 +03:00
assert . equal ( graphStore . readCheckpoints (). filter (( c ) => c . chatId === chat1 . id ). length , 2 );
2026-08-22 10:30:00 +03:00
});
2026-08-31 22:36:41 +03:00
it ( "merges a second session onto the same nodes and accumulates edge provenance" , () => {
// chat2 re-teaches the same two propositions (identical statements) —
// the conservative identity resolution recognizes them.
2026-08-22 10:30:00 +03:00
const tree2 = createTree (
[
2026-08-31 22:36:41 +03:00
{ id : "a" , title : "Alpha intuition" },
{ id : "b" , title : "Beta rule" },
2026-08-22 10:30:00 +03:00
],
2026-08-31 22:36:41 +03:00
[{ from : "a" , to : "b" }],
2026-08-22 10:30:00 +03:00
);
const chat2 = fixtureChat ( "Graph fixture 2" , tree2 , [
user ( "u1" , "again" ),
assistant ( "a1" , [ planPart ( "p1" , "plan_init" , { nodes : [], edges : [] }, tree2 )]),
]);
2026-08-31 22:36:41 +03:00
const stats = mergeFindings ( chat2 . id , treeFindings ( tree2 ), graphDeps );
2026-08-22 10:30:00 +03:00
assert . deepEqual ( stats , { nodes : 0 , edges : 1 , events : 0 });
chat2Id = chat2 . id ;
const after = scopedGraph ([ chat1 . id , chat2 . id ]);
assert . equal ( after . nodes . length , 3 ); // no duplicate nodes
assert . equal ( after . edges . length , 1 ); // one merged edge…
assert . deepEqual ( after . edges [ 0 ] ! . provenance , [ chat1 . id , chat2 . id ]); // …two sessions
});
it ( "derives node states, sessions, and node provenance in graphSnapshot" , () => {
2026-08-31 20:00:12 +03:00
const snap = graphSnapshot ( graphDeps );
2026-08-22 10:30:00 +03:00
const idByStatement = new Map ( snap . nodes . map (( n ) => [ n . statement , n . id ]));
assert . equal ( snap . nodeStates [ idByStatement . get ( "Alpha intuition" ) ! ], "taught" );
assert . equal ( snap . nodeStates [ idByStatement . get ( "Beta rule" ) ! ], "missed" );
2026-08-31 22:36:41 +03:00
// Gamma: no taught/checks — but the rescued dont-know probe (call-5)
// makes its latest event a dont-know.
assert . equal ( snap . nodeStates [ idByStatement . get ( "Gamma application" ) ! ], "dont-know" );
2026-08-22 10:30:00 +03:00
assert . equal ( snap . sessions [ chat1 . id ], "Graph fixture" );
assert . equal ( snap . sessions [ chat2Id ], "Graph fixture 2" );
assert . deepEqual (
( snap . nodeSessions [ idByStatement . get ( "Alpha intuition" ) ! ] ?? []). sort (),
[ chat1 . id , chat2Id ]. sort (),
);
2026-08-31 22:36:41 +03:00
// Gamma was only ever touched by chat1.
2026-08-22 10:30:00 +03:00
assert . deepEqual ( snap . nodeSessions [ idByStatement . get ( "Gamma application" ) ! ], [ chat1 . id ]);
});
it ( "returns zeros for unknown chats" , () => {
2026-08-31 22:36:41 +03:00
assert . deepEqual ( mergeFindings ( "chat_does_not_exist" , treeFindings ( fixtureTree ()), graphDeps ), {
nodes : 0 ,
edges : 0 ,
events : 0 ,
});
2026-08-22 10:30:00 +03:00
});
});
2026-08-31 22:36:41 +03:00
// ─── the trigger helper ──────────────────────────────────────────────────────
2026-08-22 10:30:00 +03:00
2026-08-31 22:36:41 +03:00
describe ( "the trigger helper (pendingQuizAnswers)" , () => {
it ( "counts answered quizzes not yet in the ledger — dismissed/ask never count" , () => {
2026-08-22 10:30:00 +03:00
const tree = createTree ([{ id : "solo" , title : "Solo node" }], []);
2026-08-31 22:36:41 +03:00
const chat = fixtureChat ( "Trigger fixture" , tree , [
2026-08-22 10:30:00 +03:00
user ( "u1" , "go" ),
assistant ( "a1" , [ planPart ( "p1" , "plan_init" , { nodes : [], edges : [] }, tree )]),
assistant ( "a2" , [
quizPart (
"call-s" ,
{ question : "QS" , nodeId : "solo" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-s" , questionText : "QS text" , correct : true , answeredAt : "2026-08-22T03:00:00.000Z" }),
),
2026-08-31 22:36:41 +03:00
quizPart (
"call-s2" ,
{ question : "QS2" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-s2" , questionText : "QS2 text" , dismissed : true }),
),
{
type : "tool-ask_user_question" ,
toolCallId : "call-s3" ,
state : "output-available" ,
input : { question : "free?" },
output : answer ({ questionId : "call-s3" , kind : "ask" , questionText : "free?" , text : "y" }),
},
2026-08-22 10:30:00 +03:00
]),
]);
2026-08-31 22:36:41 +03:00
assert . equal ( pendingQuizAnswers ( chat . id , graphDeps ), 1 , "one answered quiz pending" );
assert . equal ( pendingQuizAnswers ( "chat_does_not_exist" , graphDeps ), 0 );
2026-08-22 10:30:00 +03:00
2026-08-31 22:36:41 +03:00
mergeFindings ( chat . id , treeFindings ( tree ), graphDeps );
assert . equal ( pendingQuizAnswers ( chat . id , graphDeps ), 0 , "extracted → nothing pending" );
2026-08-22 10:30:00 +03:00
2026-08-31 22:36:41 +03:00
// A NEW answered quiz raises the count again (the re-trigger path).
chatStore . saveMessages ( chat . id , [
... chatStore . getChat ( chat . id ) ! . messages ,
assistant ( "a3" , [
quizPart (
"call-s4" ,
{ question : "QS4" , nodeId : "solo" , options : [{ label : "a" }, { label : "b" }], correctAnswer : "a" , explanation : "e" },
answer ({ questionId : "call-s4" , questionText : "QS4 text" , correct : false , answeredAt : "2026-08-22T03:01:00.000Z" }),
),
]),
]);
assert . equal ( pendingQuizAnswers ( chat . id , graphDeps ), 1 );
2026-08-22 10:30:00 +03:00
});
});
// ─── store primitives ────────────────────────────────────────────────────────
describe ( "graph ledger store" , () => {
it ( "round-trips lines and skips corrupt / foreign-shaped lines (strict reads)" , () => {
2026-09-01 03:34:47 +03:00
const node = { id : "n_storetest" , name : "Store test" , statement : "Store test" , aliases : [], kind : "truth" as const , createdAt : "2026-08-22T00:00:00.000Z" };
2026-08-31 20:00:12 +03:00
graphStore . appendNode ( node );
2026-08-22 10:30:00 +03:00
// A torn append and a legacy V0-shaped node (different schema) — both skipped.
fs . appendFileSync (
LEDGER_FILES . nodes ,
`{not json} \ n ${ JSON . stringify ({ id : "legacy" , statement : "Legacy shape" , domain : "x" , state : "known" } )} \ n` ,
"utf-8" ,
);
2026-08-31 20:00:12 +03:00
const nodes = graphStore . readNodes (). filter (( n ) => n . id === "n_storetest" || n . id === "legacy" );
2026-08-22 10:30:00 +03:00
assert . equal ( nodes . length , 1 );
assert . equal ( nodes [ 0 ] ! . statement , "Store test" );
2026-09-01 03:34:47 +03:00
assert . equal ( nodes [ 0 ] ! . name , "Store test" );
});
it ( "derives a name for pre-name ledger lines (legacy reads)" , () => {
// A full, valid node line written before `name` existed.
fs . appendFileSync (
LEDGER_FILES . nodes ,
` ${ JSON . stringify ({ id : "n_prename" , statement : "Derivatives measure instantaneous change; they are limits." , aliases : [], kind : "truth" , createdAt : "2026-08-22T00:00:00.000Z" } )} \ n` ,
"utf-8" ,
);
const node = graphStore . readNodes (). find (( n ) => n . id === "n_prename" );
assert . ok ( node );
assert . equal ( node . name , "Derivatives measure instantaneous change" );
2026-08-22 10:30:00 +03:00
});
});