2026-08-31 22:36:41 +03:00
/**
2026-09-28 16:54:44 +03:00
* The extraction pipeline tests (vision §1 construction, 2026-09-02 redesign;
* relinked 2026-09-27): extractor → auditor → merger, the sole graph writer —
* the extractor alone sees the source (whole-session markdown), the auditor
* never does (identity linking through graph tools), the merger is
* deterministic.
2026-08-31 22:36:41 +03:00
*
2026-09-28 16:54:44 +03:00
* transcriptView — the markdown rendering the extractor sees (pure):
* id-anchored Teacher/Learner blocks + check blocks
2026-09-12 02:34:18 +03:00
* pipeline — scripted faux engines drive both agents: findings out,
2026-09-08 22:19:37 +03:00
* audit verdict (with linkTo identity) in, deterministic
* merge applied; failure paths checkpoint nothing;
* single-flight per chat
2026-08-31 22:36:41 +03:00
*
2026-09-28 16:54:44 +03:00
* The merger itself (identity verdicts, idempotency) is tested in
* tests/graph.test.ts directly against mergeFindings — here it is the
* integration: what one full pipeline run does to the ledgers.
2026-08-31 22:36:41 +03:00
*
* Ledger: in-memory (the GraphStore port) — this suite never touches
* data/graph. Chats: real ChatRecords in the real chat store (created and
* deleted by this suite).
*/
import assert from "node:assert/strict" ;
2026-10-23 04:43:56 +03:00
import { createHash } from "node:crypto" ;
2026-08-31 22:36:41 +03:00
import * as fs from "node:fs" ;
import { after , describe , it } from "node:test" ;
2026-10-23 17:25:10 +03:00
import { createFauxCore , fauxAssistantMessage , fauxToolCall , type FauxResponseStep , type ToolCall , type Usage } from "@earendil-works/pi-ai" ;
2026-09-12 02:34:18 +03:00
import type { PiEngine } from "../src/agent/models.ts" ;
2026-09-26 00:37:46 +03:00
import type { LhMessage } from "../src/core/native-messages.ts" ;
import { newPlanBlockMessage } from "../src/core/native-messages.ts" ;
2026-09-26 01:52:47 +03:00
import { projectView as projectNative } from "../src/core/view.ts" ;
2026-09-08 22:19:37 +03:00
import type {
2026-09-28 19:22:58 +03:00
AuditVerdicts ,
2026-09-15 17:41:56 +03:00
CoverageEntry ,
2026-09-13 02:08:55 +03:00
ExtractionRunUsage ,
2026-09-30 03:22:52 +03:00
GraphChain ,
2026-09-08 22:19:37 +03:00
GraphNode ,
2026-09-29 02:25:32 +03:00
2026-09-13 02:08:55 +03:00
NotesSource ,
2026-09-08 22:19:37 +03:00
SessionCheckpoint ,
SessionFindings ,
} from "../src/core/contracts.ts" ;
2026-08-31 22:36:41 +03:00
import type { GraphStore } from "../src/core/ports.ts" ;
2026-09-30 02:09:25 +03:00
import {
abortExtraction ,
createExtractionPipeline ,
2026-10-23 04:43:56 +03:00
NotesImportError ,
2026-09-30 02:09:25 +03:00
extractionStatuses ,
transcriptView ,
2026-09-30 17:44:37 +03:00
sourceDigest ,
2026-09-30 02:09:25 +03:00
EXTRACTION_CORE ,
SESSION_PREAMBLE ,
2026-10-13 08:26:31 +03:00
SESSION_MESSAGE_ID_RULE ,
2026-09-30 02:09:25 +03:00
NOTES_PREAMBLE ,
EXTRACTOR_INSTRUCTIONS ,
NOTES_EXTRACTOR_INSTRUCTIONS ,
} from "../src/agent/subagents/extraction.ts" ;
2026-10-13 08:26:31 +03:00
import { getGraph , simplifyChains } from "../src/core/graph.ts" ;
2026-09-24 22:51:12 +03:00
import { ModelsNotConfiguredError , ProviderKeyMissingError , providerKeyMissing , type ModelSlotName } from "../src/core/settings.ts" ;
2026-10-13 08:26:31 +03:00
import { coverageOf , coverageSlots , messageViewLines } from "../src/core/coverage.ts" ;
2026-08-31 22:36:41 +03:00
import { chatStore } from "../src/store/chats.ts" ;
2026-10-02 17:31:35 +03:00
import { subscribe } from "../src/core/events.ts" ;
2026-08-31 22:36:41 +03:00
import { PATHS } from "../config.ts" ;
// ─── fixtures ────────────────────────────────────────────────────────────────
const createdChats : string [] = [];
2026-09-12 02:34:18 +03:00
/** A scripted pi engine (faux provider, docs/pi-engine-swap.md §7): the
* response queue replaces the old doGenerate turn counter. Usage is
* ESTIMATED by faux from the prompt/response text (deterministic, > 0) —
* the tests assert structure and positivity, not flat mock numbers. */
function fauxEngine ( steps : FauxResponseStep []) : PiEngine {
const faux = createFauxCore ({});
faux . setResponses ( steps );
return { model : faux.getModel (), streamFn : faux.streamSimple };
2026-08-31 22:36:41 +03:00
}
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
function memoryLedgers() {
const nodes : GraphNode [] = [];
2026-09-30 03:22:52 +03:00
const chains : GraphChain [] = [];
2026-08-31 22:36:41 +03:00
const checkpoints : SessionCheckpoint [] = [];
2026-09-15 17:41:56 +03:00
const coverage : CoverageEntry [] = [];
2026-09-13 02:08:55 +03:00
const sources = new Map < string , NotesSource >();
2026-09-13 08:35:42 +03:00
const positions = new Map < string , { x : number ; y : number }>();
2026-09-23 15:37:34 +03:00
return {
2026-08-31 22:36:41 +03:00
appendNode : ( n : GraphNode ) => void nodes . push ( n ),
readNodes : () => nodes . map (( n ) => ({ ... n })),
2026-09-30 03:22:52 +03:00
appendChain : ( c : GraphChain ) => void chains . push ( c ),
readChains : () => chains . map (( c ) => ({ ... c , premises : [... c . premises ], provenance : [... c . provenance ] })),
2026-08-31 22:36:41 +03:00
appendCheckpoint : ( c : SessionCheckpoint ) => void checkpoints . push ( c ),
readCheckpoints : () => checkpoints . map (( c ) => ({ ... c })),
2026-09-15 17:41:56 +03:00
// Extraction coverage (vision-extraction-coverage.md) — append-only;
// the chat pipeline checkpoints one entry per covered message.
appendCoverage : ( e ) => void coverage . push ({ ... e }),
readCoverage : () => coverage . map (( e ) => ({ ... e })),
2026-09-13 02:08:55 +03:00
upsertSource : ( s : NotesSource ) => {
// Mirrors the real store: an upsert KEEPS the existing usage log.
const prev = sources . get ( s . id );
sources . set ( s . id , { ... s , extractions : [...( prev ? . extractions ?? [])] });
},
readSources : () => [... sources . values ()]. map (( s ) => ({ ... s , extractions : [...( s . extractions ?? [])] })),
appendSourceUsage : ( id : string , entry : ExtractionRunUsage ) => {
const source = sources . get ( id );
if ( ! source ) return ;
if ( ! source . extractions ? . some (( r ) => r . startedAt === entry . startedAt )) {
source . extractions = [...( source . extractions ?? []), entry ];
}
},
2026-09-13 03:59:48 +03:00
// Cluster labelings — mirrors the real store: append dedupes by id.
2026-09-13 08:35:42 +03:00
// The layout ledger — settleGraphLayout writes it after each merge.
readPositions : () => Object . fromEntries ([... positions ]. map (([ id , p ]) => [ id , { ... p }])),
writePositions : ( next ) => {
positions . clear ();
for ( const [ id , p ] of Object . entries ( next )) positions . set ( id , { x : p.x , y : p.y });
},
2026-09-23 02:41:04 +03:00
// The destructive seam (wave 6 session deletion) — mirrors the real
// store: replace the provided ledgers' full contents.
rewriteLedgers : ( next ) => {
if ( next . nodes ) nodes . splice ( 0 , nodes . length , ... next . nodes . map (( n ) => ({ ... n , provenance : [... n . provenance ], aliases : [... n . aliases ] })));
2026-09-30 03:22:52 +03:00
if ( next . chains ) chains . splice ( 0 , chains . length , ... next . chains . map (( c ) => ({ ... c , premises : [... c . premises ], provenance : [... c . provenance ] })));
2026-09-23 02:41:04 +03:00
if ( next . coverage ) coverage . splice ( 0 , coverage . length , ... next . coverage . map (( c ) => ({ ... c })));
if ( next . checkpoints ) checkpoints . splice ( 0 , checkpoints . length , ... next . checkpoints . map (( c ) => ({ ... c })));
},
2026-08-31 22:36:41 +03:00
} satisfies GraphStore ;
}
2026-09-26 00:37:46 +03:00
// Native fixture builders (Wave B): the record holds pi-native history —
// an assistant message with its tool calls + one result message per call;
// the projection reproduces the part shapes these view tests pin.
interface NativeCallDesc {
name : string ;
toolCallId : string ;
input : Record < string , unknown >;
output : unknown ;
2026-08-31 22:36:41 +03:00
}
2026-09-26 00:37:46 +03:00
function user ( id : string , text : string ) : LhMessage {
return { id , role : "user" , content : text , timestamp : Date.now () } as never ;
2026-08-31 22:36:41 +03:00
}
2026-09-26 00:37:46 +03:00
function assistant ( id : string , items : unknown []) : LhMessage [] {
const content : unknown [] = [];
const calls : NativeCallDesc [] = [];
for ( const item of items ) {
const it = item as { type ?: string ; text? : string };
if ( it && it . type === "text" ) {
content . push ({ type : "text" , text : it.text });
} else {
const call = item as NativeCallDesc ;
calls . push ( call );
content . push ({ type : "toolCall" , id : call.toolCallId , name : call.name , arguments : call.input as never });
}
}
const out : LhMessage [] = [
{
id ,
role : "assistant" ,
content ,
api : "openai-completions" ,
provider : "openrouter" ,
model : "test" ,
usage : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 },
stopReason : "stop" ,
timestamp : Date.now (),
} as never ,
];
for ( const call of calls ) {
out . push ({
id : `r_ ${ call . toolCallId } ` ,
role : "toolResult" ,
toolCallId : call.toolCallId ,
toolName : call.name ,
content : [{ type : "text" , text : "ok" }],
details : call.output ,
isError : false ,
timestamp : Date.now (),
} as never );
}
return out ;
2026-08-31 22:36:41 +03:00
}
2026-09-26 00:37:46 +03:00
function quizPart ( toolCallId : string , input : Record < string , unknown >, output : Record < string , unknown >) : NativeCallDesc {
return { name : "quiz" , toolCallId , input , output };
}
function planPart ( toolCallId : string , name : string , input : Record < string , unknown >, tree : unknown ) : NativeCallDesc {
return { name , toolCallId , input , output : { ok : true , tree } };
2026-08-31 22:36:41 +03:00
}
2026-09-08 22:19:37 +03:00
/** A session-shaped fixture chat: probe → teaching text → check. Tree-blind
* world: the plan tree is NOT part of extraction — it is noise. */
2026-08-31 22:36:41 +03:00
function fixtureChat() {
const chat = chatStore . createChat ( "Extraction fixture" );
createdChats . push ( chat . id );
chatStore . saveMessages ( chat . id , [
user ( "u1" , "teach me the chain rule" ),
2026-09-26 00:37:46 +03:00
... assistant ( "a1" , [
2026-09-28 22:37:18 +03:00
{ type : "text" , text : "The chain rule composes rates of change along a path." },
2026-08-31 22:36:41 +03:00
quizPart ( "probe-1" , { question : "What is a derivative, roughly?" }, {
2026-10-02 04:04:04 +03:00
kind : "quiz" , questionText : "What is a derivative, roughly?" ,
2026-08-31 22:36:41 +03:00
correct : false , dontKnow : false , answeredAt : "2026-08-22T01:00:00.000Z" ,
}),
]),
2026-09-26 00:37:46 +03:00
// Distinct TURNS: the native grouping canon merges consecutive
// assistant messages into one view group, so the fixture separates
// its turns with learner messages (the real record's shape).
user ( "u2" , "go on" ),
... assistant ( "a2" , [
2026-08-31 22:36:41 +03:00
{ type : "text" , text : "The derivative measures how fast a function changes." },
2026-09-08 22:19:37 +03:00
planPart ( "p1" , "plan_init" , { nodes : [], edges : [] }, { nodes : [], edges : [], currentNodeId : null , doneNodeIds : [], version : 1 }),
2026-08-31 22:36:41 +03:00
]),
2026-09-26 00:37:46 +03:00
user ( "u3" , "check me" ),
... assistant ( "a3" , [
2026-09-08 22:19:37 +03:00
quizPart ( "check-1" , { question : "Check" }, {
2026-10-02 04:04:04 +03:00
kind : "quiz" , questionText : "Check" , correct : true , dontKnow : false ,
2026-08-31 22:36:41 +03:00
answeredAt : "2026-08-22T02:00:00.000Z" ,
}),
]),
2026-09-26 00:37:46 +03:00
]. flat ());
2026-08-31 22:36:41 +03:00
return { chatId : chat.id , record : chatStore.getChat ( chat . id ) ! };
}
after (() => {
for ( const id of createdChats ) {
try {
fs . unlinkSync ( ` ${ PATHS . chats } / ${ id } .json` );
} catch {
/* already gone */
}
}
});
2026-09-30 02:09:25 +03:00
/** What the extractor SUBMITS for the fixture session (owner redesign,
* 2026-09-30): propositions + REASONING CHAINS on the wire — one chain
2026-10-13 08:26:31 +03:00
* (p1 ⟹ p2). Each proposition carries its passage anchor (2026-10-13):
* the fixture's teacher-prose messages are a1 (chain rule prose) and a2
* (derivative prose) — p1 anchors to a2, p2 to a1. */
2026-09-30 02:09:25 +03:00
const FINDINGS = {
2026-08-31 22:36:41 +03:00
propositions : [
{
id : "p1" ,
2026-09-09 00:40:02 +03:00
name : "Derivative" ,
2026-09-08 22:19:37 +03:00
statement : "The derivative measures how fast a function changes" ,
2026-08-31 22:36:41 +03:00
aliases : [],
2026-10-13 08:26:31 +03:00
messageId : "a2" ,
2026-08-31 22:36:41 +03:00
},
{
id : "p2" ,
2026-09-09 00:40:02 +03:00
name : "Chain rule" ,
statement : "The chain rule differentiates composed functions by multiplying local rates." ,
2026-08-31 22:36:41 +03:00
aliases : [],
2026-10-13 08:26:31 +03:00
messageId : "a1" ,
2026-08-31 22:36:41 +03:00
},
],
2026-09-30 02:09:25 +03:00
chains : [
2026-09-08 22:19:37 +03:00
{
2026-09-30 02:09:25 +03:00
premises : [ "p1" ],
conclusion : "p2" ,
2026-09-08 22:19:37 +03:00
},
],
2026-08-31 22:36:41 +03:00
};
2026-09-12 02:34:18 +03:00
/** An engine whose ONE response is the `name` tool call with the given
* input — the whole deliverable; the pipeline stops after a clean submit
* (shouldStop), so no trailing text step exists anymore. `onCall` fires at
* the moment of the model call — the observation point for mid-run
* assertions. */
function submitModel ( name : string , input : unknown , onCall ?: () => void ) : PiEngine {
return fauxEngine ([
( context ) => {
onCall ? .();
void context ;
return fauxAssistantMessage (
2026-09-26 21:45:51 +03:00
[ fauxToolCall ( name , input as ToolCall [ "arguments" ], { id : ` ${ name } -call` })],
2026-09-12 02:34:18 +03:00
{ stopReason : "toolUse" },
);
2026-08-31 22:36:41 +03:00
},
2026-09-12 02:34:18 +03:00
]);
2026-08-31 22:36:41 +03:00
}
2026-09-28 18:00:56 +03:00
/** A submit model whose single response WAITS on a gate before answering —
* the timing control for the serialized-audit tests (owner 2026-09-28):
* the model call has STARTED (onCall fired, the run holds the audit
* gate) but will not resolve until the test opens the gate. */
function gatedSubmitModel ( name : string , input : unknown , gate : Promise < void >, onCall ?: () => void ) : PiEngine {
return fauxEngine ([
async () => {
onCall ? .();
await gate ;
return fauxAssistantMessage (
[ fauxToolCall ( name , input as ToolCall [ "arguments" ], { id : ` ${ name } -call` })],
{ stopReason : "toolUse" },
);
},
]);
}
/** Poll until true — the queued-phase observations need real ticks. */
async function until ( cond : () => boolean , ms = 2000 ) : Promise < void > {
for ( const t0 = Date . now (); ! cond (); ) {
if ( Date . now () - t0 > ms ) throw new Error ( "condition never became true in time" );
await new Promise (( r ) => setTimeout ( r , 2 ));
}
}
2026-09-12 02:34:18 +03:00
/** An engine whose model call never resolves until its abort signal fires
* — mimicking a cancelled provider request (the abort path). Like a real
* provider, an ALREADY-aborted signal resolves immediately (the abort can
* win the race before the call attaches its listener); the faux stream
* then sees the aborted signal and ends the message as aborted. */
function hangingModel () : PiEngine {
return fauxEngine ([
( _context , options ) =>
new Promise (( resolve ) => {
const sig = options ? . signal ;
if ( sig ? . aborted ) {
resolve ( fauxAssistantMessage ( "late" ));
return ;
}
sig ? . addEventListener ( "abort" , () => resolve ( fauxAssistantMessage ( "late" )), { once : true });
}),
]);
2026-09-11 03:25:25 +03:00
}
2026-09-12 02:34:18 +03:00
/** An engine that never calls a tool (the failure path). */
function silentModel () : PiEngine {
return fauxEngine ([ fauxAssistantMessage ( "I refuse to use tools" ), fauxAssistantMessage ( "…" )]);
2026-08-31 22:36:41 +03:00
}
// ─── transcriptView (pure) ───────────────────────────────────────────────────
2026-09-28 22:37:18 +03:00
describe ( "transcriptView (pure — teacher prose only, owner 2026-09-28)" , () => {
it ( "renders ONLY the teacher's prose — no learner, no checks, no tool notes" , () => {
2026-08-31 22:36:41 +03:00
const { record } = fixtureChat ();
const view = transcriptView ( record );
2026-09-28 22:37:18 +03:00
// The document header is the session title; the body is the
2026-10-13 08:26:31 +03:00
// teacher's text blocks as labeled passages.
2026-09-28 16:54:44 +03:00
assert . match ( view , /^# Extraction fixture$/m );
2026-10-13 08:26:31 +03:00
assert . match ( view , /^TEACHER \[a1\] The chain rule composes rates of change along a path\.$/m );
assert . match ( view , /^TEACHER \[a2\] The derivative measures how fast a function changes\.$/m );
// MESSAGE-ID LABELS (owner, 2026-10-13): the passages carry their
// ACTUAL message ids, in exactly messageViewLines' shape — the same
// seam the coverage fingerprints hash. No label without prose: a3
// (quiz only) and the learner messages are not passages.
assert . doesNotMatch ( view , /TEACHER \[a3\]/ );
assert . doesNotMatch ( view , /TEACHER \[u1\]/ );
assert . equal (
view . slice ( view . indexOf ( "TEACHER" )),
record . messages . flatMap (( m ) => messageViewLines ( m , m . id )). join ( "\n\n" ),
"the view body IS the messageViewLines of the transcript — one shared seam" ,
);
2026-08-31 22:36:41 +03:00
2026-09-28 22:37:18 +03:00
// EVERYTHING else is gone: learner text, quiz blocks, tool notes,
// speaker headers — the ruling strips it all.
assert . doesNotMatch ( view , /teach me the chain rule/ );
assert . doesNotMatch ( view , /go on/ );
assert . doesNotMatch ( view , /check me/ );
assert . doesNotMatch ( view , /### / );
assert . doesNotMatch ( view , /Quiz/ );
assert . doesNotMatch ( view , /plan_init/ );
2026-08-31 22:36:41 +03:00
});
2026-09-28 22:37:18 +03:00
it ( "job tool results and empty text vanish — only prose paragraphs remain" , () => {
2026-08-31 22:36:41 +03:00
const chat = chatStore . createChat ( "View noise fixture" );
createdChats . push ( chat . id );
chatStore . saveMessages ( chat . id , [
user ( "u1" , "go" ),
2026-09-26 00:37:46 +03:00
... assistant ( "a1" , [
2026-08-31 22:36:41 +03:00
{
2026-09-26 00:37:46 +03:00
name : "research" ,
2026-08-31 22:36:41 +03:00
toolCallId : "r1" ,
input : { question : "q" },
output : { brief : "long brief" , sources : [] },
},
{ type : "text" , text : " " },
2026-09-28 22:37:18 +03:00
{ type : "text" , text : "Prose survives." },
2026-08-31 22:36:41 +03:00
]),
2026-09-26 00:37:46 +03:00
]. flat ());
2026-08-31 22:36:41 +03:00
const view = transcriptView ( chatStore . getChat ( chat . id ) ! );
2026-10-13 08:26:31 +03:00
assert . match ( view , /^TEACHER \[a1\] Prose survives\.$/m , "the surviving passage is labeled with its real id" );
2026-09-28 22:37:18 +03:00
assert . doesNotMatch ( view , /research/ );
2026-08-31 22:36:41 +03:00
assert . doesNotMatch ( view , /long brief/ );
2026-09-28 22:37:18 +03:00
assert . doesNotMatch ( view , /### / );
2026-08-31 22:36:41 +03:00
});
});
// ─── the pipeline ───────────────────────────────────────────────────────────
describe ( "the extraction pipeline (extractor → auditor → merger)" , () => {
2026-10-02 17:31:35 +03:00
it ( "push S5: every registry mutation announces activity-changed; reads stay silent" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const verdict : AuditVerdicts = { links : [] };
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
auditor : submitModel ( "submit_review" , verdict ),
});
let announces = 0 ;
const off = subscribe (( e ) => {
if ( e . type === "activity-changed" ) announces ++ ;
});
// A read announces nothing (reads are silent — the bus hygiene law).
extractionStatuses ();
assert . equal ( announces , 0 );
await pipeline . runExtraction ( chatId );
// The run announced its lifecycle: setPhase at registration and every
// phase transition, finishExtraction at the exit — several events,
// and NONE after settle (the registry is empty; nothing left to say).
assert . ok ( announces >= 2 , `phases + finish announced (got ${ announces } )` );
const afterSettle = announces ;
extractionStatuses ();
assert . equal ( announces , afterSettle , "reads stay silent" );
off ();
});
2026-09-28 19:04:39 +03:00
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 ();
2026-09-29 01:16:32 +03:00
const verdict : AuditVerdicts = { links : [] };
2026-09-28 19:04:39 +03:00
// 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 ),
2026-09-30 02:09:25 +03:00
chains : FINDINGS.chains ,
2026-09-28 19:04:39 +03:00
});
2026-09-30 17:44:37 +03:00
const pipeline = createExtractionPipeline ({ chatStore , graphStore : ledgers , extractor , auditor : submitModel ( "submit_review" , verdict ) });
2026-09-28 19:04:39 +03:00
const stats = await pipeline . runExtraction ( chatId );
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 2 , chains : 1 });
2026-09-28 19:04:39 +03:00
});
2026-09-30 02:09:25 +03:00
it ( "stringified-payload heal: the CHAINS array arriving as ONE JSON string still validates (redesign, 2026-09-30)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const extractor = submitModel ( "submit_findings" , {
propositions : FINDINGS.propositions ,
chains : JSON.stringify ( FINDINGS . chains ),
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 1 });
2026-09-30 02:09:25 +03:00
});
2026-09-08 22:19:37 +03:00
it ( "runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint" , async () => {
2026-08-31 22:36:41 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-08 22:19:37 +03:00
// The auditor passes p1 through and LINKS it to a pre-existing node
2026-09-28 16:54:44 +03:00
// (identity verdict); p2 stays a verdict-free create.
2026-09-08 22:19:37 +03:00
const existingId = "n_existing" ;
ledgers . appendNode ({
id : existingId ,
2026-09-09 00:40:02 +03:00
name : "Derivative" ,
2026-09-08 22:19:37 +03:00
statement : "Derivatives measure rates of change" ,
aliases : [],
createdAt : "2026-08-01T00:00:00.000Z" ,
provenance : [],
});
2026-09-28 19:22:58 +03:00
const verdict : AuditVerdicts = {
links : [{ id : FINDINGS.propositions [ 0 ] ! . id , linkTo : existingId }],
2026-08-31 22:36:41 +03:00
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
2026-09-08 22:19:37 +03:00
auditor : submitModel ( "submit_review" , verdict ),
2026-08-31 22:36:41 +03:00
});
const stats = await pipeline . runExtraction ( chatId );
2026-09-28 19:22:58 +03:00
// VERDICTS-ONLY (2026-09-28): findings merge in FULL — the linked
// p1 creates no node, p2 creates one, and the extractor's edge
// resolves through them. (The old echo form could silently drop
// content by omitting it; that accidental drop channel is gone.)
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 1 , chains : 1 });
2026-08-31 22:36:41 +03:00
2026-09-08 22:19:37 +03:00
// Link-append: the phrasing joined the existing node's aliases and
2026-09-28 19:22:58 +03:00
// the provenance unioned; p2's create line is its own node.
assert . equal ( ledgers . readNodes (). length , 3 ); // pre-seeded + link line + p2's create
2026-09-29 02:25:32 +03:00
const linkedLines = ledgers . readNodes (). filter (( n ) => n . id === existingId );
assert . ok (
linkedLines . some (( line ) => line . provenance . some (( pt ) => pt . chatId === chatId )),
"the link-append unions provenance" ,
);
2026-10-13 08:26:31 +03:00
// ANCHORS (2026-10-13): both lines carry their proposition's passage
// id — p1's link-append anchors to a2 (its passage), p2's create to a1.
assert . ok (
linkedLines . some (( line ) => line . provenance . some (( pt ) => pt . chatId === chatId && pt . messageId === "a2" )),
"the link-append line carries p1's passage id" ,
);
const created = ledgers . readNodes (). find (( n ) => n . name === "Chain rule" ) ! ;
assert . deepEqual ( created . provenance , [{ chatId , messageId : "a1" }], "the created node anchors to p2's passage" );
2026-08-31 22:36:41 +03:00
2026-09-15 17:41:56 +03:00
// COVERAGE checkpointed per message (not a whole-chat checkpoint):
// one entry per transcript message, fingerprinted content each —
// the same turns are never extracted twice.
2026-09-28 16:54:44 +03:00
const record = chatStore . getChat ( chatId ) ! ;
2026-09-15 17:41:56 +03:00
const covered = ledgers . readCoverage ();
2026-09-26 01:52:47 +03:00
// Wave D: the coverage unit is the VISIBLE native message — bonded
// riders with no view lines (a textless call message) are context,
// never checkpointed.
const visible = record . messages . filter (
( m ) => messageViewLines ( m , m . id ). length > 0 ,
). length ;
assert . equal ( covered . length , visible );
2026-09-15 17:41:56 +03:00
assert . ok ( covered . every (( c ) => c . chatId === chatId && c . fp . length === 64 && c . tokens > 0 ));
assert . equal ( ledgers . readCheckpoints (). length , 0 , "the chat path writes coverage, not the legacy checkpoint" );
2026-09-22 16:58:25 +03:00
2026-09-15 17:41:56 +03:00
assert . equal (
2026-09-26 01:52:47 +03:00
coverageOf ( record . messages , covered , chatId ). uncoveredCount ,
2026-09-15 17:41:56 +03:00
0 ,
"every message is now covered" ,
);
2026-08-31 22:36:41 +03:00
});
2026-09-22 16:58:25 +03:00
it ( "BYOK refusal: no stage engines anywhere → the run throws honestly before any stage (owner, 2026-09-22)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// No direct engines AND no engineFor — the chain came up empty.
2026-09-23 15:37:34 +03:00
const pipeline = createExtractionPipeline ({ chatStore , graphStore : ledgers });
2026-09-22 16:58:25 +03:00
await assert . rejects ( pipeline . runExtraction ( chatId ), ( err : unknown ) => {
assert . ok ( err instanceof ModelsNotConfiguredError );
2026-09-30 17:44:37 +03:00
assert . deepEqual ( err . envelope . missing , [ "cheap" ]); // extractor + auditor, both cheap-tier
2026-09-22 16:58:25 +03:00
assert . equal ( err . envelope . code , "models_not_configured" );
return true ;
});
// nothing ran: no phase residue, no usage, no coverage writes
assert . equal ( ledgers . readCoverage (). length , 0 );
});
2026-09-24 22:51:12 +03:00
it ( "key-presence refusal: a stage engine whose provider key is gone throws before any batch (provider-key interlude, 2026-09-25)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const extractor = fauxEngine ([]);
const auditor = fauxEngine ([]);
const seen : Array < [ string , ModelSlotName ] > = [];
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor ,
providerKeyMissing : ( provider , tier ) => {
seen . push ([ provider , tier ]);
return providerKeyMissing ( provider , [ tier ]);
},
});
await assert . rejects ( pipeline . runExtraction ( chatId ), ( err : unknown ) => {
assert . ok ( err instanceof ProviderKeyMissingError );
assert . equal ( err . envelope . code , "provider_key_missing" );
assert . equal ( err . envelope . provider , extractor . model . provider , "the provider of the keyless engine" );
assert . deepEqual ( err . envelope . slots , [ "cheap" ], "extractor + auditor are the cheap tier" );
return true ;
});
// FIRST offender surfaces (extractor before auditor) — and nothing
// ran: the refusal lands before any batch, no coverage writes.
assert . deepEqual ( seen , [[ extractor . model . provider , "cheap" ]]);
assert . equal ( ledgers . readCoverage (). length , 0 );
});
it ( "key-presence pass-through: null from the check lets a stage through — the NEXT keyless stage surfaces (first-offender discipline)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
let calls = 0 ;
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : fauxEngine ([]),
auditor : fauxEngine ([]),
2026-09-30 17:44:37 +03:00
providerKeyMissing : ( provider , tier ) => ( calls ++ < 1 ? null : providerKeyMissing ( provider , [ tier ])),
2026-09-24 22:51:12 +03:00
});
await assert . rejects ( pipeline . runExtraction ( chatId ), ( err : unknown ) => {
2026-09-30 17:44:37 +03:00
assert . ok ( err instanceof ProviderKeyMissingError , "the auditor's refusal surfaces after the extractor passed" );
2026-09-24 22:51:12 +03:00
return true ;
});
2026-09-30 17:44:37 +03:00
assert . equal ( calls , 2 , "both stages were checked, in order" );
2026-09-24 22:51:12 +03:00
assert . equal ( ledgers . readCoverage (). length , 0 );
});
2026-09-23 15:01:41 +03:00
it ( "the LLM cluster-namer is DEAD (wave 7): a merge mints no naming runs — labels are session names now" , async () => {
2026-09-22 16:58:25 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-28 19:22:58 +03:00
// Empty verdicts: everything creates (verdicts-only, 2026-09-28).
2026-09-29 01:16:32 +03:00
const verdict : AuditVerdicts = { links : [] };
2026-09-22 16:58:25 +03:00
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
auditor : submitModel ( "submit_review" , verdict ),
});
const stats = await pipeline . runExtraction ( chatId );
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 2 , chains : 1 }); // full findings merge (verdicts-only)
2026-09-23 15:01:41 +03:00
// Wave 7 (950ddfd): birth-session clusters carry session-name labels,
// so the post-merge naming garnish is gone — no model spend, no
// naming-run records, no label writes.
2026-09-22 16:58:25 +03:00
});
2026-09-22 13:59:16 +03:00
it ( "engineFor fills the stages (model slots wave): the chain replaces config pins" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-29 01:16:32 +03:00
const verdict : AuditVerdicts = { links : [] };
2026-09-22 13:59:16 +03:00
const consulted : string [] = [];
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
// NO direct values — the stages resolve ENTIRELY through engineFor
// (the role override → slot → factory-pin chain, read fresh per
// stage call in production; here, the scripted chain).
engineFor : ( role ) => {
consulted . push ( role );
if ( role === "extractor" ) return submitModel ( "submit_findings" , FINDINGS );
if ( role === "auditor" ) return submitModel ( "submit_review" , verdict );
return submitModel ( "submit_labels" , { labels : [] });
},
});
const stats = await pipeline . runExtraction ( chatId );
assert . equal ( stats . nodes , 2 , "the chain-fed pipeline ran end to end — both propositions merged" );
2026-09-30 16:38:45 +03:00
// The stages were resolved through the chain, in pipeline order
2026-09-30 17:44:37 +03:00
// (extractor → auditor; the labeler is fire-and-forget
2026-09-30 16:38:45 +03:00
// garnish for any other role).
2026-09-30 17:44:37 +03:00
assert . deepEqual ( consulted . slice ( 0 , 2 ), [ "extractor" , "auditor" ]);
2026-09-22 13:59:16 +03:00
});
it ( "direct test values still WIN over engineFor (the priority pin)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-29 01:16:32 +03:00
const verdict : AuditVerdicts = { links : [] };
2026-09-22 13:59:16 +03:00
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
auditor : submitModel ( "submit_review" , verdict ),
// If the direct values did NOT win, this throw would surface.
engineFor : ( role ) => {
throw new Error ( `engineFor should not be consulted for ${ role } when a direct value is set` );
},
});
const stats = await pipeline . runExtraction ( chatId );
assert . equal ( stats . nodes , 2 , "the direct-value seams ran, untouched by engineFor" );
});
2026-09-29 00:34:21 +03:00
it ( "a failing stage is ISOLATED (per-chunk jobs, owner 2026-09-28): the job fails, nothing merges — and a sibling batch still merges" , async () => {
2026-08-31 22:36:41 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
auditor : silentModel (), // never submits the review
});
2026-09-29 00:34:21 +03:00
// The old run-level throw is gone: one failed job is absorbed at
// the fan-out — the run resolves with what actually merged (zeros).
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 0 , chains : 0 });
2026-08-31 22:36:41 +03:00
assert . equal ( ledgers . readNodes (). length , 0 );
assert . equal ( ledgers . readCheckpoints (). length , 0 , "no checkpoint — the next trigger re-runs" );
2026-09-29 00:34:21 +03:00
assert . equal ( ledgers . readCoverage (). length , 0 );
2026-08-31 22:36:41 +03:00
const failingExtractor = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : silentModel (),
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }),
2026-08-31 22:36:41 +03:00
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await failingExtractor . runExtraction ( chatId ), { nodes : 0 , chains : 0 });
2026-08-31 22:36:41 +03:00
assert . equal ( ledgers . readCheckpoints (). length , 0 );
2026-09-29 00:34:21 +03:00
// THE isolation pin: a two-batch chat whose SECOND extractor call
// never submits — batch 1 still merges, batch 2 stays uncovered
// (honestly re-runnable). Count at stream-INVOKE time: readiness
// probes mint engines without ever calling them.
const { chatId : chunkyId , record } = chunkyChat ();
let invocations = 0 ;
const findings = findingsStep ();
const refuse = fauxAssistantMessage ( "I refuse to use tools" );
const countingStep : FauxResponseStep = ( context , options , state , model ) => {
invocations ++ ;
const step = invocations === 1 ? findings : refuse ;
return typeof step === "function" ? step ( context , options , state , model ) : step ;
};
const mixed = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-30 16:38:45 +03:00
// 4096 budget: the fixture is sized to split in two (the factory
2026-10-13 08:26:31 +03:00
// default again — halved back from the digest-era 8192, owner
// 2026-10-13).
2026-09-30 16:38:45 +03:00
getChunkTokens : () => 4096 ,
2026-09-29 00:34:21 +03:00
engineFor : ( role ) =>
2026-09-30 16:38:45 +03:00
role === "extractor"
? fauxEngine ([ countingStep ])
: submitModel ( "submit_review" , { links : [] }),
2026-09-29 00:34:21 +03:00
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await mixed . runExtraction ( chunkyId ), { nodes : 2 , chains : 1 }, "batch 1's merge survives batch 2's failure" );
2026-09-29 00:34:21 +03:00
assert . equal ( invocations , 2 );
assert . equal ( ledgers . readCoverage (). length , 3 , "batch 1 checkpointed its three messages" );
assert . equal (
coverageOf ( record . messages , ledgers . readCoverage (), chunkyId ). uncoveredCount ,
2 ,
"batch 2's messages stay uncovered for the next run" ,
);
2026-08-31 22:36:41 +03:00
});
2026-09-09 01:26:49 +03:00
it ( "reports its phase through the status registry while running, and clears it when done" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const existingId = "n_existing" ;
ledgers . appendNode ({
id : existingId ,
name : "Derivative" ,
statement : "Derivatives measure rates of change" ,
aliases : [],
createdAt : "2026-08-01T00:00:00.000Z" ,
provenance : [],
});
const seen : ( string | undefined )[] = [];
const phaseOf = () => extractionStatuses (). find (( r ) => r . chatId === chatId ) ? . phase ;
2026-09-28 19:22:58 +03:00
const verdict : AuditVerdicts = {
links : [{ id : FINDINGS.propositions [ 0 ] ! . id , linkTo : existingId }],
2026-09-09 01:26:49 +03:00
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
// Each stage's first generate is the observation point: the
// registry must already carry this chat at the right phase.
extractor : submitModel ( "submit_findings" , FINDINGS , () => seen . push ( phaseOf ())),
auditor : submitModel ( "submit_review" , verdict , () => seen . push ( phaseOf ())),
});
await pipeline . runExtraction ( chatId );
assert . deepEqual ( seen , [ "extracting" , "auditing" ]);
assert . equal ( extractionStatuses (). length , 0 , "the registry clears when the run ends" );
});
2026-09-28 18:00:56 +03:00
it ( "serialized audit (owner 2026-09-28): a second run QUEUES at the gate; its auditor explores the graph only AFTER the first run's merge" , async () => {
const a = fixtureChat ();
const b = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-30 02:09:25 +03:00
const single = ( id : string , name : string , statement : string ) => ({
2026-10-13 08:26:31 +03:00
propositions : [{ id , name , statement , aliases : [], messageId : "a1" }],
2026-09-30 02:09:25 +03:00
chains : [],
2026-09-28 18:00:56 +03:00
});
const findingsA = single ( "pa" , "Alpha claim" , "Alpha statement, full precision" );
const findingsB = single ( "pb" , "Beta claim" , "Beta statement, full precision" );
let gateOpen !: () => void ;
const gate = new Promise < void >(( resolve ) => ( gateOpen = resolve ));
let aAuditing = false ;
const pipelineA = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , findingsA ),
2026-09-29 01:16:32 +03:00
auditor : gatedSubmitModel ( "submit_review" , { links : [] }, gate , () => {
2026-09-28 18:00:56 +03:00
aAuditing = true ;
}),
});
const runA = pipelineA . runExtraction ( a . chatId );
await until (() => extractionStatuses (). some (( r ) => r . chatId === a . chatId && r . phase === "auditing" ));
assert . ok ( aAuditing , "run A holds the gate — its audit model call is in flight" );
let bAuditorCalled = false ;
let bSawAlphasMerge = false ;
const pipelineB = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , findingsB ),
// THE pin: when B's auditor is finally invoked, A's merge must
// already be in the ledgers — B explores the post-merge graph,
// which is the entire point of the gate.
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }, () => {
2026-09-28 18:00:56 +03:00
bAuditorCalled = true ;
bSawAlphasMerge = ledgers . readNodes (). some (( n ) => n . name === "Alpha claim" );
}),
});
const runB = pipelineB . runExtraction ( b . chatId );
await until (() => extractionStatuses (). some (( r ) => r . chatId === b . chatId && r . phase === "waiting" ));
assert . ok ( ! bAuditorCalled , "B's auditor has not been invoked — it queues behind A" );
assert . equal ( ledgers . readNodes (). filter (( n ) => n . name === "Beta claim" ). length , 0 , "nothing of B merged yet" );
assert . equal ( extractionStatuses (). find (( r ) => r . chatId === a . chatId ) ? . phase , "auditing" , "A still audits while B waits" );
gateOpen ();
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await runA , { nodes : 1 , chains : 0 });
assert . deepEqual ( await runB , { nodes : 1 , chains : 0 });
2026-09-28 18:00:56 +03:00
assert . ok ( bAuditorCalled );
assert . ok ( bSawAlphasMerge , "B's auditor saw A's merged node — audit serialized after merge" );
assert . equal ( extractionStatuses (). length , 0 , "both runs left the registry" );
});
it ( "serialized audit: a run ABORTED while waiting never audits — and the queue chain stays healthy for successors" , async () => {
const a = fixtureChat ();
const b = fixtureChat ();
const c = fixtureChat ();
const ledgers = memoryLedgers ();
2026-09-30 02:09:25 +03:00
const single = ( id : string , name : string , statement : string ) => ({
2026-10-13 08:26:31 +03:00
propositions : [{ id , name , statement , aliases : [], messageId : "a1" }],
2026-09-30 02:09:25 +03:00
chains : [],
2026-09-28 18:00:56 +03:00
});
let gateOpen !: () => void ;
const gate = new Promise < void >(( resolve ) => ( gateOpen = resolve ));
const pipelineA = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , single ( "pa" , "Alpha claim" , "Alpha statement" )),
2026-09-29 01:16:32 +03:00
auditor : gatedSubmitModel ( "submit_review" , { links : [] }, gate ),
2026-09-28 18:00:56 +03:00
});
const runA = pipelineA . runExtraction ( a . chatId );
await until (() => extractionStatuses (). some (( r ) => r . chatId === a . chatId && r . phase === "auditing" ));
let bAuditorCalled = false ;
const pipelineB = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , single ( "pb" , "Beta claim" , "Beta statement" )),
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }, () => {
2026-09-28 18:00:56 +03:00
bAuditorCalled = true ;
}),
});
const runB = pipelineB . runExtraction ( b . chatId );
await until (() => extractionStatuses (). some (( r ) => r . chatId === b . chatId && r . phase === "waiting" ));
// The dock's abort button, pressed while B queues.
assert . equal ( abortExtraction ( b . chatId ), true );
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await runB , { nodes : 0 , chains : 0 }, "aborted before auditing — zero emissions" );
2026-09-28 18:00:56 +03:00
assert . ok ( ! bAuditorCalled , "B's auditor was never invoked" );
assert . ok ( ! extractionStatuses (). some (( r ) => r . chatId === b . chatId ), "B left the registry" );
assert . equal ( ledgers . readCoverage (). some (( row ) => row . chatId === b . chatId ), false , "B checkpointed nothing" );
// The chain link B abandoned still resolved: A finishes, and a
// THIRD run audits normally — no deadlock residue.
gateOpen ();
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await runA , { nodes : 1 , chains : 0 });
2026-09-28 18:00:56 +03:00
const pipelineC = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , single ( "pc" , "Gamma claim" , "Gamma statement" )),
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }),
2026-09-28 18:00:56 +03:00
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipelineC . runExtraction ( c . chatId ), { nodes : 1 , chains : 0 });
2026-09-28 18:00:56 +03:00
});
2026-09-11 03:25:25 +03:00
it ( "captures per-stage usage live, persists the run's usage into the chat record" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const existingId = "n_existing" ;
ledgers . appendNode ({
id : existingId ,
name : "Derivative" ,
statement : "Derivatives measure rates of change" ,
aliases : [],
createdAt : "2026-08-01T00:00:00.000Z" ,
provenance : [],
});
const usageSeen : ( number | undefined )[] = [];
2026-09-28 19:22:58 +03:00
const verdict : AuditVerdicts = {
links : [{ id : FINDINGS.propositions [ 0 ] ! . id , linkTo : existingId }],
2026-09-11 03:25:25 +03:00
};
2026-10-23 17:25:10 +03:00
const nativeUsage : Record < "extractor" | "auditor" , Usage [] > = { extractor : [], auditor : [] };
const recordUsage = ( stage : "extractor" | "auditor" , engine : PiEngine ) : PiEngine => ({
... engine ,
streamFn : ( model , context , options ) => {
const stream = engine . streamFn ( model , context , options );
void stream . result (). then (( message ) => nativeUsage [ stage ]. push ( message . usage ));
return stream ;
},
});
2026-09-11 03:25:25 +03:00
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
// During the auditor's generate, the registry must already carry
// the extractor's fully accumulated live usage (2 steps × 10/5).
2026-10-23 17:25:10 +03:00
extractor : recordUsage ( "extractor" , submitModel ( "submit_findings" , FINDINGS )),
auditor : recordUsage ( "auditor" , submitModel ( "submit_review" , verdict , () =>
2026-09-11 03:25:25 +03:00
usageSeen . push ( extractionStatuses (). find (( r ) => r . chatId === chatId ) ? . usage . extractor ? . steps . at ( - 1 ) ? . inputTokens ),
2026-10-23 17:25:10 +03:00
)),
2026-09-11 03:25:25 +03:00
});
await pipeline . runExtraction ( chatId );
2026-09-12 02:34:18 +03:00
// Live: mid-audit the extractor's usage was fully accumulated (faux
// estimates tokens from the prompt/response text — deterministic,
2026-10-23 17:25:10 +03:00
// positive, with native first-conversation cache writes in 1.4).
2026-09-12 02:34:18 +03:00
assert . equal ( usageSeen . length , 1 );
assert . ok ( typeof usageSeen [ 0 ] === "number" && usageSeen [ 0 ] ! > 0 , "the extractor's step billed input tokens" );
2026-09-11 03:25:25 +03:00
// Persisted: the run appended ONE usage entry to the chat's
// extractions log — per-stage model + steps, with merge stats.
const record = chatStore . getChat ( chatId ) ! ;
assert . equal ( record . extractions ? . length , 1 );
const run = record . extractions ! [ 0 ] ! ;
2026-09-28 19:22:58 +03:00
assert . equal ( run . stats ? . nodes , 1 ); // p2 created; p1 linked, not created
2026-09-22 00:14:56 +03:00
// the scripted engine's model, provider-qualified (model slots wave)
assert . equal ( run . stages . extractor . model , "faux:faux-1" );
2026-09-12 02:34:18 +03:00
assert . equal ( run . stages . extractor . steps . length , 1 ); // the submit call — the run STOPS after a clean submit
const step = run . stages . extractor . steps [ 0 ] ! ;
assert . ok ( step . inputTokens ! > 0 && step . outputTokens ! > 0 , "estimated usage captured" );
assert . equal ( step . cacheReadTokens , 0 );
2026-10-23 17:25:10 +03:00
assert . equal ( nativeUsage . extractor . length , 1 );
assert . ok ( nativeUsage . extractor [ 0 ] ! . cacheWrite > 0 , "fresh native conversation writes its cached prefix" );
assert . equal ( step . cacheReadTokens , nativeUsage . extractor [ 0 ] ! . cacheRead );
assert . equal ( step . cacheWriteTokens , nativeUsage . extractor [ 0 ] ! . cacheWrite );
2026-09-12 02:34:18 +03:00
assert . equal ( step . reasoningTokens , 0 );
2026-10-23 17:25:10 +03:00
assert . equal ( nativeUsage . auditor . length , 1 );
assert . equal ( run . stages . auditor . steps [ 0 ] ! . cacheReadTokens , 0 , "auditor owns a fresh conversation too" );
assert . ok ( nativeUsage . auditor [ 0 ] ! . cacheWrite > 0 );
assert . equal ( run . stages . auditor . steps [ 0 ] ! . cacheReadTokens , nativeUsage . auditor [ 0 ] ! . cacheRead );
assert . equal ( run . stages . auditor . steps [ 0 ] ! . cacheWriteTokens , nativeUsage . auditor [ 0 ] ! . cacheWrite );
2026-09-12 02:34:18 +03:00
assert . equal ( run . stages . auditor . steps . length , 1 );
2026-09-11 03:25:25 +03:00
assert . ok ( ! Number . isNaN ( Date . parse ( run . at )));
});
it ( "aborts mid-run: cancels the model call, persists partial usage, checkpoints nothing" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : hangingModel (), // hangs until aborted — no step finishes
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }),
2026-09-11 03:25:25 +03:00
});
const runPromise = pipeline . runExtraction ( chatId );
// The registry entry exists synchronously after the call (setPhase
// runs before the first await); abort is TRUE cancellation.
assert . equal ( abortExtraction ( chatId ), true );
const stats = await runPromise ;
// A deliberate stop, not a failure: zero emissions, no throw.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 0 , chains : 0 });
2026-09-11 03:25:25 +03:00
assert . equal ( extractionStatuses (). length , 0 , "the registry clears" );
assert . equal ( ledgers . readNodes (). length , 0 , "nothing merged" );
assert . equal ( ledgers . readCheckpoints (). length , 0 , "nothing checkpointed — the next trigger re-runs" );
// Partial usage still persisted (money was spent): stats absent,
// no completed steps in either stage.
const record = chatStore . getChat ( chatId ) ! ;
assert . equal ( record . extractions ? . length , 1 );
const run = record . extractions ! [ 0 ] ! ;
assert . equal ( run . stats , undefined );
assert . equal ( run . stages . extractor . steps . length , 0 );
assert . equal ( run . stages . auditor . steps . length , 0 );
// Aborting a non-running chat is an idempotent no-op.
assert . equal ( abortExtraction ( chatId ), false );
});
2026-08-31 22:36:41 +03:00
it ( "is single-flight per chat — a concurrent second run is a no-op" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// The extractor's first call hangs until we release it.
let release !: () => void ;
const gate = new Promise < void >(( resolve ) => {
release = resolve ;
});
let calls = 0 ;
2026-09-12 02:34:18 +03:00
const gatedExtractor = fauxEngine ([
async () => {
2026-08-31 22:36:41 +03:00
calls ++ ;
if ( calls === 1 ) await gate ;
2026-09-12 02:34:18 +03:00
return fauxAssistantMessage ( "…" );
2026-08-31 22:36:41 +03:00
},
2026-09-12 02:34:18 +03:00
]);
2026-08-31 22:36:41 +03:00
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : gatedExtractor ,
auditor : silentModel (),
});
const first = pipeline . runExtraction ( chatId );
const second = await pipeline . runExtraction ( chatId ); // in-flight → zeros
2026-09-30 03:22:52 +03:00
assert . deepEqual ( second , { nodes : 0 , chains : 0 });
2026-08-31 22:36:41 +03:00
release ();
2026-09-29 00:34:21 +03:00
// The gated extractor never submits — an ISOLATED job failure now
// (per-chunk jobs, owner 2026-09-28): the run resolves with zeros
// instead of throwing.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await first , { nodes : 0 , chains : 0 });
2026-08-31 22:36:41 +03:00
});
2026-09-19 13:16:34 +03:00
it ( "registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// Gated extractor: the first call hangs until released, so the run
// stays registered while we observe it.
let release !: () => void ;
const gate = new Promise < void >(( resolve ) => {
release = resolve ;
});
let calls = 0 ;
const gatedExtractor = fauxEngine ([
async () => {
calls ++ ;
if ( calls === 1 ) await gate ;
return fauxAssistantMessage ( "…" );
},
]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : gatedExtractor ,
auditor : silentModel (),
});
// A SECOND instance over DIFFERENT ledgers — the observer a separate
// route entry's module instance is in dev (the turn-liveness lesson:
// module instances split; globalThis does not).
const observer = createExtractionPipeline ({
chatStore ,
graphStore : memoryLedgers (),
extractor : silentModel (),
auditor : silentModel (),
});
const first = pipeline . runExtraction ( chatId );
// The synchronous prefix (setPhase at batch start) registers BEFORE
// the first await — the run is visible the moment it starts.
assert . ok (
extractionStatuses (). some (( run ) => run . chatId === chatId && run . phase === "extracting" ),
"the run is registered at start" ,
);
const g = globalThis as { __lhExtractionRuns ?: { running : Map < string , unknown > } };
assert . ok ( g . __lhExtractionRuns ? . running . has ( chatId ), "the registry lives on globalThis — no module-instance split can hide runs" );
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await observer . runExtraction ( chatId ), { nodes : 0 , chains : 0 }, "single-flight spans pipeline instances" );
2026-09-19 13:16:34 +03:00
release ();
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await first , { nodes : 0 , chains : 0 }, "isolated job failure — the run resolves zeros" );
2026-09-19 13:16:34 +03:00
assert . ok ( ! extractionStatuses (). some (( run ) => run . chatId === chatId ), "the registry entry clears when the run ends (however it ends)" );
assert . ok ( ! g . __lhExtractionRuns ? . running . has ( chatId ), "nothing leaks into the global registry" );
});
2026-08-31 22:36:41 +03:00
it ( "throws for unknown chats" , async () => {
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : silentModel (),
auditor : silentModel (),
});
await assert . rejects ( pipeline . runExtraction ( "chat_does_not_exist" ), /Chat not found/ );
});
});
2026-09-13 02:08:55 +03:00
2026-10-13 08:26:31 +03:00
// ─── message anchors (messageId — owner, 2026-10-13) ────────────────────────
describe ( "message anchors (session propositions carry their passage's id)" , () => {
/** A single-proposition submit with an explicit anchor. */
const anchored = ( messageId : string ) => ({
propositions : [
{ id : "p1" , name : "Anchor claim" , statement : "Anchor claim, full precision." , aliases : [], messageId },
],
chains : [],
});
/** One scripted extractor turn: a submit_findings tool call. */
const submitStep = ( payload : unknown ) : FauxResponseStep => () =>
fauxAssistantMessage (
[ fauxToolCall ( "submit_findings" , payload as ToolCall [ "arguments" ], { id : "anchor-call" })],
{ stopReason : "toolUse" },
);
it ( "a submit MISSING messageId never passes: the schema rejects every turn — nothing merges, nothing checkpoints" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// The degraded shape: propositions without anchors, chains present —
// only the REQUIRED field is missing, and the schema refuses it on
// every turn until the budget is gone (the required-field doctrine,
// 2026-09-28, now covering the anchor too).
const bare = () =>
fauxEngine (
Array . from ({ length : 4 }, () =>
submitStep ({
propositions : [{ id : "p1" , name : "Anchor claim" , statement : "Anchor claim, full precision." , aliases : [] }],
chains : [],
}),
),
);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
engineFor : ( role ) => ( role === "extractor" ? bare () : submitModel ( "submit_review" , { links : [] })),
});
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 0 , chains : 0 });
assert . equal ( ledgers . readNodes (). length , 0 , "nothing merged" );
assert . equal ( ledgers . readCoverage (). length , 0 , "nothing checkpointed — the batch stays re-runnable" );
});
it ( "an EMPTY or UNKNOWN anchor rides the error-back channel: refused, the model re-anchors clean, then merges" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// Three bad anchors, all schema-valid (messageId is a string) — only
// the anchor law refuses them: empty, a LEARNER message of this very
// chat (real id, not a teacher passage), and pure fiction.
const extractor = fauxEngine ([
submitStep ( anchored ( "" )),
submitStep ( anchored ( "u1" )),
submitStep ( anchored ( "made_up" )),
submitStep ( anchored ( "a2" )),
]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 1 , chains : 0 }, "the retry anchored and merged" );
const node = ledgers . readNodes ()[ 0 ] ! ;
assert . deepEqual ( node . provenance , [{ chatId , messageId : "a2" }], "the node anchors to its passage" );
});
it ( "the boundary is the CURRENT BATCH: a real teacher message id from another batch is refused, the in-batch retry merges" , async () => {
const { chatId } = chunkyChat (); // deterministic batches [a1,a2,a3] + [a4,a5] at the 4096 budget
const ledgers = memoryLedgers ();
// Batch 1: "a5" is a REAL teacher-prose id of this very chat — but it
// lives in batch 2, so this stretch refuses it; the re-anchor to "a2"
// merges. Batch 2 anchors to its own "a4".
const extractor = fauxEngine ([
submitStep ( anchored ( "a5" )),
submitStep ( anchored ( "a2" )),
submitStep ( anchored ( "a4" )),
]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
getChunkTokens : () => 4096 ,
extractor ,
// One review per batch job (the retry lives in the extractor's
// own turn budget, not the auditor's).
auditor : fauxEngine ([ passThroughAuditStep (), passThroughAuditStep ()]),
});
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 0 });
const anchors = ledgers . readNodes (). map (( n ) => n . provenance [ 0 ] ! . messageId ). sort ();
assert . deepEqual ( anchors , [ "a2" , "a4" ], "each job's node anchors INSIDE its own batch — never a5" );
assert . equal ( ledgers . readCoverage (). length , 5 , "both batches checkpointed" );
});
it ( "a LINK-APPEND carries its proposition's anchor into the existing node's provenance" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const existingId = "n_anchor_existing" ;
ledgers . appendNode ({
id : existingId ,
name : "Derivative" ,
statement : "Derivatives measure rates of change" ,
aliases : [],
createdAt : "2026-08-01T00:00:00.000Z" ,
provenance : [],
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , anchored ( "a2" )),
auditor : submitModel ( "submit_review" , { links : [{ id : "p1" , linkTo : existingId }] }),
});
await pipeline . runExtraction ( chatId );
// The link-append line — the re-encounter — carries the passage id.
const appended = ledgers
. readNodes ()
. filter (( n ) => n . id === existingId )
. find (( line ) => line . provenance . some (( pt ) => pt . chatId === chatId )) ! ;
assert . deepEqual ( appended . provenance , [{ chatId , messageId : "a2" }]);
// And the derived graph unions it under the same point identity.
const merged = getGraph ( ledgers ). nodes . find (( n ) => n . id === existingId ) ! ;
assert . ok ( merged . provenance . some (( pt ) => pt . chatId === chatId && pt . messageId === "a2" ));
});
it ( "the notes door stays anchor-free: no messageId required — and a stray one never reaches provenance" , async () => {
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
// NOTES_FINDINGS carries no messageId (the notes schema never
// asks for one) — the submit validates exactly as before.
extractor : submitModel ( "submit_findings" , NOTES_FINDINGS ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
assert . deepEqual ( await pipeline . importNotes ({ filePath : "/vault/anchor-free.md" , text : NOTES_TEXT }), { nodes : 1 , chains : 0 });
assert . deepEqual (
ledgers . readNodes ()[ 0 ] ! . provenance ,
[{ chatId : ledgers.readSources ()[ 0 ] ! . id , messageId : "" }],
"notes provenance stays chat-level — the file is the anchor" ,
);
// The boundary: even a hallucinated messageId (extra properties pass
// schema validation) is stripped at the notes fill seam.
const stray = {
propositions : [{ ... NOTES_FINDINGS . propositions [ 0 ], messageId : "made_up" }],
chains : [],
};
const second = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , stray ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
assert . deepEqual ( await second . importNotes ({ filePath : "/vault/stray-anchor.md" , text : NOTES_TEXT }), { nodes : 1 , chains : 0 });
const secondSource = ledgers . readSources ()[ 1 ] ! ;
const strayNode = ledgers . readNodes (). find (( n ) => n . provenance . some (( pt ) => pt . chatId === secondSource . id )) ! ;
assert . deepEqual ( strayNode . provenance , [{ chatId : secondSource.id , messageId : "" }], "the stray anchor was stripped before the merger" );
});
it ( "coverage fingerprints are UNCHANGED by the anchors: the stored fps equal the plain coverage-slot hashes" , async () => {
const { chatId , record } = chunkyChat ();
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
getChunkTokens : () => 4096 ,
extractor : fauxEngine ([ findingsStep (), findingsStep ()]),
auditor : fauxEngine ([ passThroughAuditStep (), passThroughAuditStep ()]),
});
await pipeline . runExtraction ( chatId );
// The labeled view reuses messageViewLines; the fingerprint seam is
// the same lines it always was — same hashes, same coverage.
assert . deepEqual (
ledgers . readCoverage (). map (( c ) => c . fp ). sort (),
coverageSlots ( record . messages ). map (( slot ) => slot . fp ). sort (),
);
assert . equal ( coverageOf ( record . messages , ledgers . readCoverage (), chatId ). uncoveredCount , 0 );
});
});
2026-09-13 02:08:55 +03:00
// ── the notes front door (format-agnostic import) ────────────────────────────
/** Raw personal notes — arbitrary format, no session, no message ids. */
const NOTES_TEXT = `> [!quote] YOU
> teach me vim
> [!abstract] PI
> Vim always starts in Normal mode — keys are commands, not text.` ;
2026-09-30 02:09:25 +03:00
/** What the notes-mode extractor submits: same wire shape as session
* findings (propositions + chains), messages always empty (the file is
* the provenance). */
2026-09-13 02:08:55 +03:00
const NOTES_FINDINGS = {
propositions : [
{
id : "p1" ,
name : "Vim normal mode" ,
statement : "Vim starts in Normal mode, where keys are commands." ,
aliases : [],
},
],
2026-09-30 02:09:25 +03:00
chains : [],
2026-09-13 02:08:55 +03:00
} as const ;
const NOTES_VERDICT = {
2026-09-28 21:28:39 +03:00
links : [],
2026-09-13 02:08:55 +03:00
edges : [],
} as const ;
2026-09-30 02:09:25 +03:00
// ─── the extractor redesign (owner, 2026-09-30) ─────────────────────────────
describe ( "the extractor redesign (owner, 2026-09-30): chains are the deliverable" , () => {
/** A retry engine: step 1 submits the BAD payload (schema-valid, law-
* violating — so the pin proves the VALIDATE channel fired, not the
* schema), step 2 submits the clean one. */
function retryEngine ( bad : unknown , good : unknown ) : PiEngine {
return fauxEngine ([
() =>
fauxAssistantMessage ([ fauxToolCall ( "submit_findings" , bad as ToolCall [ "arguments" ], { id : "findings-bad" })], {
stopReason : "toolUse" ,
}),
() =>
fauxAssistantMessage ([ fauxToolCall ( "submit_findings" , good as ToolCall [ "arguments" ], { id : "findings-good" })], {
stopReason : "toolUse" ,
}),
]);
}
2026-09-30 03:22:52 +03:00
it ( "co-premises ride as ONE stored argument — the fan-out to arrows is the derived view, not the store" , async () => {
2026-09-30 02:09:25 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const extractor = submitModel ( "submit_findings" , {
propositions : [
2026-10-13 08:26:31 +03:00
{ id : "p1" , name : "A" , statement : "A holds." , aliases : [], messageId : "a1" },
{ id : "p2" , name : "B" , statement : "B holds." , aliases : [], messageId : "a2" },
{ id : "p3" , name : "C" , statement : "A and B together entail C." , aliases : [], messageId : "a1" },
2026-09-30 02:09:25 +03:00
],
chains : [{ premises : [ "p1" , "p2" ], conclusion : "p3" }],
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 3 , chains : 1 });
// The stored chain's premises are exactly the two premises; the
// conclusion is C's resolved node (ids are random — assert
// set-wise, never by ledger order).
2026-09-30 02:09:25 +03:00
const byName = new Map ( ledgers . readNodes (). map (( n ) => [ n . name , n . id ]));
const conclusion = byName . get ( "C" ) ! ;
2026-09-30 03:22:52 +03:00
const chains = ledgers . readChains ();
assert . ok ( chains . every (( c ) => c . conclusion === conclusion ), "chains point INTO the conclusion" );
2026-09-30 02:09:25 +03:00
assert . deepEqual (
2026-09-30 03:22:52 +03:00
chains . flatMap (( c ) => c . premises ). sort (),
2026-09-30 02:09:25 +03:00
[ byName . get ( "A" ) ! , byName . get ( "B" ) ! ]. sort (),
);
});
it ( "two chains sharing a conclusion union their premises; a bare proposition stays an edge-less node" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const extractor = submitModel ( "submit_findings" , {
propositions : [
2026-10-13 08:26:31 +03:00
{ id : "p1" , name : "A" , statement : "A holds." , aliases : [], messageId : "a1" },
{ id : "p2" , name : "B" , statement : "B holds." , aliases : [], messageId : "a2" },
{ id : "p3" , name : "C" , statement : "C follows." , aliases : [], messageId : "a1" },
{ id : "p4" , name : "Lonely" , statement : "A bare proposition no argument uses." , aliases : [], messageId : "a2" },
2026-09-30 02:09:25 +03:00
],
chains : [
{ premises : [ "p1" ], conclusion : "p3" },
{ premises : [ "p2" ], conclusion : "p3" },
],
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
// Independent arguments for the same conclusion are separate chains;
// the bare p4 rides the propositions list alone.
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 4 , chains : 2 });
2026-09-30 02:09:25 +03:00
const lonely = ledgers . readNodes (). find (( n ) => n . name === "Lonely" ) ! ;
2026-09-30 03:22:52 +03:00
assert . ok ( ledgers . readChains (). every (( c ) => ! c . premises . includes ( lonely . id ) && c . conclusion !== lonely . id ));
2026-09-30 02:09:25 +03:00
});
it ( "identical chains dedupe SILENTLY at the expansion — restating an argument is a no-op, not an error" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const extractor = submitModel ( "submit_findings" , {
... FINDINGS ,
chains : [ FINDINGS . chains [ 0 ] ! , FINDINGS . chains [ 0 ] ! ],
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 1 });
2026-09-30 02:09:25 +03:00
});
it ( "the OLD wire shape (an edges array, no chains) is dead: validation fails every turn — nothing merges" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const oldShape = () =>
fauxEngine (
Array . from ({ length : 4 }, () => () =>
fauxAssistantMessage (
[
fauxToolCall (
"submit_findings" ,
{
propositions : FINDINGS.propositions ,
edges : [{ from : "p1" , to : "p2" , type : "follows" }],
} as unknown as ToolCall [ "arguments" ],
),
],
{ stopReason : "toolUse" },
),
),
);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
engineFor : ( role ) => ( role === "extractor" ? oldShape () : submitModel ( "submit_review" , { links : [] })),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 0 , chains : 0 });
2026-09-30 02:09:25 +03:00
});
it ( "validate error-back: a chain referencing an UNKNOWN id is rejected, the model re-submits clean" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const bad = {
... FINDINGS ,
chains : [{ premises : [ "p1" , "p9" ], conclusion : "p2" }],
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : retryEngine ( bad , FINDINGS ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
// The first submit was schema-VALID — only the chain law caught it;
// the retry merges in full.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 1 });
2026-09-30 02:09:25 +03:00
});
it ( "validate error-back: a conclusion in its OWN premises is rejected, the model re-submits clean" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const bad = {
... FINDINGS ,
chains : [{ premises : [ "p1" , "p2" ], conclusion : "p2" }],
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : retryEngine ( bad , FINDINGS ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 1 });
2026-09-30 02:09:25 +03:00
});
it ( "empty premises never pass: the schema's minItems rejects, the model re-submits clean" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const bad = {
... FINDINGS ,
chains : [{ premises : [], conclusion : "p2" }],
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : retryEngine ( bad , FINDINGS ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 2 , chains : 1 });
2026-09-30 02:09:25 +03:00
});
it ( "the system prompt is goal-first: ONE shared core, both preambles assembled onto it" , () => {
// The core carries the goal + submission mechanics.
assert . match ( EXTRACTION_CORE , /JOINTLY entail a conclusion/ );
assert . match ( EXTRACTION_CORE , /for the same conclusion are separate chains/ );
2026-09-30 17:44:37 +03:00
assert . match ( EXTRACTION_CORE , /Entailment is the only link that earns a\s+chain/ );
2026-09-30 05:36:28 +03:00
assert . match ( EXTRACTION_CORE , /what a reference on it would teach/ );
2026-09-30 17:44:37 +03:00
assert . match ( EXTRACTION_CORE , /stand\s+alone for a reader who never saw this source/ );
assert . match ( EXTRACTION_CORE , /may not lean on the source's own\s+scaffolding/ );
2026-09-30 02:09:25 +03:00
assert . match ( EXTRACTION_CORE , /SUBMIT with one submit_findings call/ );
2026-09-30 17:44:37 +03:00
assert . match ( EXTRACTION_CORE , /"chains": \[\] when the source makes no\s+arguments/ );
// The digest doctrine (2026-09-30 evening) + the core-ideas language.
assert . match ( EXTRACTION_CORE , /THE GOAL: uncover the source's CORE IDEAS/ );
assert . match ( EXTRACTION_CORE , /everything derives from the foundations/ );
assert . match ( EXTRACTION_CORE , /build\s+on it\./ );
2026-09-30 02:09:25 +03:00
// Assembly: each front door = its preamble + the shared core.
assert . ok ( EXTRACTOR_INSTRUCTIONS . startsWith ( SESSION_PREAMBLE ));
assert . ok ( EXTRACTOR_INSTRUCTIONS . includes ( EXTRACTION_CORE ));
assert . ok ( NOTES_EXTRACTOR_INSTRUCTIONS . startsWith ( NOTES_PREAMBLE ));
assert . ok ( NOTES_EXTRACTOR_INSTRUCTIONS . includes ( EXTRACTION_CORE ), "the notes extractor shares the core by construction" );
2026-10-13 08:26:31 +03:00
// THE ANCHOR RULE (owner, 2026-10-13) is SESSION-ONLY: the exact
// sentence rides the session instructions; the notes
// contextualization never mentions message ids at all.
assert . equal (
SESSION_MESSAGE_ID_RULE ,
"For each new proposition, copy the message ID of the passage that most directly states or explains it into messageId." ,
);
assert . ok ( EXTRACTOR_INSTRUCTIONS . includes ( SESSION_MESSAGE_ID_RULE ), "the session instructions carry the rule" );
assert . ok ( ! NOTES_EXTRACTOR_INSTRUCTIONS . includes ( "messageId" ), "the notes prompt never mentions message ids" );
2026-09-30 02:09:25 +03:00
// The preambles differ in exactly their source description.
assert . notEqual ( SESSION_PREAMBLE , NOTES_PREAMBLE );
2026-09-30 17:44:37 +03:00
assert . match ( SESSION_PREAMBLE , /a teaching\s+session, rendered as the teacher's prose in reading order/ );
assert . match ( NOTES_PREAMBLE , /the\s+author's personal notes, rendered in reading order/ );
2026-09-30 02:09:25 +03:00
// NO RULE ARMOR (owner, 2026-09-30): the defensive lists are CUT
// from both contextualizations.
for ( const prompt of [ EXTRACTOR_INSTRUCTIONS , NOTES_EXTRACTOR_INSTRUCTIONS ]) {
assert . doesNotMatch ( prompt , /DROP:/ );
assert . doesNotMatch ( prompt , /MERGE YOUR OWN OUTPUT/ );
assert . doesNotMatch ( prompt , /WHEN IN DOUBT, OMIT/ );
assert . doesNotMatch ( prompt , /Faithfulness:/ );
assert . doesNotMatch ( prompt , /follows\/supports/ );
}
});
});
2026-09-13 02:08:55 +03:00
describe ( "the notes front door (importNotes — format-agnostic)" , () => {
2026-09-28 16:54:44 +03:00
it ( "imports raw notes end to end: source record, file-scoped provenance" , async () => {
2026-09-13 02:08:55 +03:00
const ledgers = memoryLedgers ();
const seen : { kind? : string ; label? : string }[] = [];
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , NOTES_FINDINGS ),
// During the auditor's call the registry must show the notes run.
auditor : submitModel ( "submit_review" , NOTES_VERDICT , () => {
const run = extractionStatuses (). find (( r ) => r . chatId . startsWith ( "src_" ));
seen . push ({ kind : run?.kind , label : run?.label });
}),
});
const stats = await pipeline . importNotes ({ filePath : "/vault/learn vim.md" , text : NOTES_TEXT });
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 1 , chains : 0 });
2026-09-13 02:08:55 +03:00
// The run surfaced as a NOTES run labeled with the file path.
assert . deepEqual ( seen , [{ kind : "notes" , label : "/vault/learn vim.md" }]);
assert . equal ( extractionStatuses (). length , 0 , "the registry clears when the run ends" );
// The source record: deterministic src id, the file path, the usage log.
const source = ledgers . readSources ()[ 0 ] ! ;
assert . match ( source . id , /^src_/ );
assert . equal ( source . filePath , "/vault/learn vim.md" );
assert . ok ( Date . parse ( source . importedAt ));
assert . equal ( source . extractions ? . length , 1 , "the run's usage persisted into the source record" );
assert . equal ( source . extractions ! [ 0 ] ! . stats ? . nodes , 1 );
assert . ok ( source . extractions ! [ 0 ] ! . stages . extractor . steps . length > 0 , "faux usage captured" );
2026-09-28 19:04:39 +03:00
// File-scoped provenance (submit-schema slim, 2026-09-28): the
// node carries ONE chat-level point — the source key itself, no
// message anchors.
2026-09-13 02:08:55 +03:00
const node = ledgers . readNodes ()[ 0 ] ! ;
assert . equal ( node . statement , "Vim starts in Normal mode, where keys are commands." );
2026-09-28 19:04:39 +03:00
assert . deepEqual ( node . provenance , [{ chatId : source.id , messageId : "" }]);
2026-09-13 02:08:55 +03:00
// The checkpoint stamps the import.
assert . equal ( ledgers . readCheckpoints ()[ 0 ] ! . chatId , source . id );
});
2026-09-28 21:28:39 +03:00
it ( "chunks a large file: one extract→audit→merge per chunk, part headers in each view (owner, 2026-09-28)" , async () => {
const ledgers = memoryLedgers ();
const views : string [] = [];
let calls = 0 ;
// Fresh engine PER CHUNK via the engineFor factory — same seam
// production uses (one scripted response per model call).
const extractorStep = () =>
fauxEngine ([
( context ) => {
calls ++ ;
views . push ( capturedView ( context ));
return fauxAssistantMessage (
[
fauxToolCall ( "submit_findings" , NOTES_FINDINGS as unknown as ToolCall [ "arguments" ], {
id : `findings- ${ calls } ` ,
}),
],
{ stopReason : "toolUse" },
);
},
]);
const text = Array . from ({ length : 8 }, ( _ , i ) => `note line ${ i } : ${ "x" . repeat ( 30 ) } ` ). join ( "\n" );
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
engineFor : ( role ) =>
role === "extractor"
? extractorStep ()
2026-09-30 16:38:45 +03:00
: submitModel ( "submit_review" , { links : [] }),
2026-09-28 21:28:39 +03:00
getChunkTokens : () => 20 , // 80 chars/line-groups → several chunks
});
const stats = await pipeline . importNotes ({ filePath : "/vault/big.md" , text });
assert . ok ( calls >= 2 , `expected ≥2 extractor calls, got ${ calls } ` );
assert . match ( views [ 0 ] ! , new RegExp ( `part 1/ ${ calls } ` ));
assert . match ( views . at ( - 1 ) ! , new RegExp ( `part ${ calls } / ${ calls } ` ));
2026-09-29 02:25:32 +03:00
// Same findings in every chunk → one node PER CHUNK: jobs merge
// independently (owner, 2026-09-29 — the mappings ledger is gone);
// cross-chunk identity is the auditor's linkTo verdict, and these
// fixtures submit none.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : calls , chains : 0 });
2026-09-29 02:25:32 +03:00
assert . equal ( ledgers . readNodes (). length , calls );
2026-09-28 21:28:39 +03:00
});
2026-09-30 02:09:25 +03:00
it ( "required top-level fields (owner, 2026-09-28; chains since 2026-09-30): a findings submit MISSING chains fails validation — no silent structure-less merge" , async () => {
2026-09-28 21:28:39 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// The degraded-retry shape from the OpenRouter log: propositions
// only, edges omitted. Required schema → validation fails on every
// turn → the run rejects instead of merging a pile of structureless
// nodes. Four queued attempts exhaust the extractor's turn budget.
// Four queued attempts — one per turn of the extractor's budget.
const noEdges = () =>
fauxEngine (
Array . from ({ length : 4 }, () => ( context ) => {
void context ;
return fauxAssistantMessage (
[ fauxToolCall ( "submit_findings" , { propositions : FINDINGS.propositions } as unknown as ToolCall [ "arguments" ])],
{ stopReason : "toolUse" },
);
}),
);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-29 01:16:32 +03:00
engineFor : ( role ) => ( role === "extractor" ? noEdges () : submitModel ( "submit_review" , { links : [] })),
2026-09-28 21:28:39 +03:00
});
2026-09-29 00:34:21 +03:00
// Isolated job failure (per-chunk jobs, owner 2026-09-28): the run
// resolves zeros — no throw — and still nothing merged.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( await pipeline . runExtraction ( chatId ), { nodes : 0 , chains : 0 });
2026-09-28 21:28:39 +03:00
assert . equal ( ledgers . readNodes (). length , 0 );
2026-09-29 00:34:21 +03:00
assert . equal ( ledgers . readCoverage (). length , 0 , "nothing checkpointed — the messages stay re-runnable" );
2026-09-28 21:28:39 +03:00
});
2026-10-23 04:43:56 +03:00
it ( "every explicit import runs the whole file, even identical text and legacy fingerprints" , async () => {
2026-09-13 02:08:55 +03:00
const ledgers = memoryLedgers ();
const mk = () =>
createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-23 15:37:34 +03:00
extractor : submitModel ( "submit_findings" , NOTES_FINDINGS ),
2026-09-13 02:08:55 +03:00
auditor : submitModel ( "submit_review" , NOTES_VERDICT ),
});
const first = await mk (). importNotes ({ filePath : "/vault/learn vim.md" , text : NOTES_TEXT });
2026-09-30 03:22:52 +03:00
assert . deepEqual ( first , { nodes : 1 , chains : 0 });
2026-09-13 02:08:55 +03:00
2026-10-23 04:43:56 +03:00
// A legacy stamp must never suppress an explicit import.
ledgers . upsertSource ({ ... ledgers . readSources ()[ 0 ] ! , contentFp : createHash ( "sha256" ). update ( NOTES_TEXT ). digest ( "hex" ). slice ( 0 , 16 ) });
2026-09-13 02:08:55 +03:00
const second = await mk (). importNotes ({ filePath : "/vault/learn vim.md" , text : NOTES_TEXT });
2026-10-23 04:43:56 +03:00
assert . deepEqual ( second , { nodes : 1 , chains : 0 }, "identical content re-extracts" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . contentFp , undefined , "new imports write no fingerprint" );
2026-09-13 02:08:55 +03:00
assert . equal ( ledgers . readSources (). length , 1 , "one source identity — the path hashes to the same id" );
2026-10-23 04:43:56 +03:00
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 , "both runs bill usage" );
assert . equal ( ledgers . readNodes (). length , 2 , "the auditor, not a content skip, owns identity" );
2026-09-29 02:25:32 +03:00
2026-10-23 04:43:56 +03:00
// Changed text also re-runs the whole file.
2026-09-29 02:25:32 +03:00
const third = await mk (). importNotes ({ filePath : "/vault/learn vim.md" , text : ` ${ NOTES_TEXT } \ nA new closing line.` });
2026-09-30 03:22:52 +03:00
assert . deepEqual ( third , { nodes : 1 , chains : 0 }, "changed content re-extracts" );
2026-10-23 04:43:56 +03:00
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 3 , "the re-run bills its usage" );
assert . equal ( ledgers . readNodes (). length , 3 );
2026-09-13 02:08:55 +03:00
});
it ( "aborts like a chat run: true cancellation, nothing merged, partial usage in the source record" , async () => {
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : hangingModel (),
auditor : submitModel ( "submit_review" , NOTES_VERDICT ),
});
const runPromise = pipeline . importNotes ({ filePath : "/vault/learn vim.md" , text : NOTES_TEXT });
const srcId = extractionStatuses (). find (( r ) => r . chatId . startsWith ( "src_" )) ! . chatId ;
assert . equal ( abortExtraction ( srcId ), true );
2026-10-23 04:43:56 +03:00
await assert . rejects ( runPromise , ( error ) => error instanceof NotesImportError && error . outcome === "aborted" && error . partialStats . nodes === 0 );
2026-09-13 02:08:55 +03:00
assert . equal ( ledgers . readNodes (). length , 0 , "nothing merged" );
assert . equal ( ledgers . readCheckpoints (). length , 0 , "nothing checkpointed" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 1 , "partial usage still persisted" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ! [ 0 ] ! . stats , undefined , "never merged → no stats" );
});
});
2026-09-15 17:41:56 +03:00
// ── the chunked pipeline (extraction coverage — vision-extraction-coverage.md) ──
/** Pull the pipeline's prompt view out of a model call: the pipeline sends
2026-09-28 16:54:44 +03:00
* the session's markdown document itself as the user turn, so the LAST
* user message's text IS what the extractor saw. */
2026-09-15 17:41:56 +03:00
function capturedView ( context : unknown ) : string {
const messages = ( context as { messages ?: { role : string ; content : string }[] }). messages ?? [];
const last = [... messages ]. reverse (). find (( m ) => m . role === "user" );
return typeof last ? . content === "string" ? last . content : "" ;
}
2026-10-13 08:26:31 +03:00
/** An extractor step that submits FINDINGS, optionally capturing its view.
* The step plays by the anchor law it runs under (2026-10-13): every
* proposition's messageId is copied from a TEACHER [id] label of the
* CURRENT stretch, so multi-batch fixtures reuse this step across batches
* without ever naming a passage their batch does not hold. */
2026-09-15 17:41:56 +03:00
function findingsStep ( capture ?: ( view : string ) => void ) : FauxResponseStep {
return ( context ) => {
2026-10-13 08:26:31 +03:00
const view = capturedView ( context );
capture ? .( view );
const anchor = /TEACHER \[([^\]]+)\]/ . exec ( view ) ? .[ 1 ] ?? "a1" ;
const submit = {
... FINDINGS ,
propositions : FINDINGS.propositions.map (( p ) => ({ ... p , messageId : anchor })),
};
2026-09-15 17:41:56 +03:00
return fauxAssistantMessage (
2026-10-13 08:26:31 +03:00
[ fauxToolCall ( "submit_findings" , submit as unknown as ToolCall [ "arguments" ], { id : "findings-call" })],
2026-09-15 17:41:56 +03:00
{ stopReason : "toolUse" },
);
};
}
/** An auditor step that passes p1 through (no linkTo — a fresh node), drops p2. */
function passThroughAuditStep () : FauxResponseStep {
return () =>
fauxAssistantMessage (
[
fauxToolCall (
"submit_review" ,
{
2026-09-28 19:22:58 +03:00
links : [],
2026-09-15 17:41:56 +03:00
edges : [],
2026-09-26 21:45:51 +03:00
} as unknown as ToolCall [ "arguments" ],
2026-09-15 17:41:56 +03:00
{ id : "review-call" },
),
],
{ stopReason : "toolUse" },
);
}
/** A session with UNCOVERED-multiple-batch mass: five long distinct messages
2026-09-24 15:12:24 +03:00
* (~1200 tok each — 3600+1200 > the factory chunk budget 4096 → batches of
2026-09-15 17:41:56 +03:00
* [m1,m2,m3] and [m4,m5]). */
function chunkyChat() {
const chat = chatStore . createChat ( "Chunky coverage fixture" );
createdChats . push ( chat . id );
2026-09-28 22:37:18 +03:00
// ~1230 tok per TEACHER message (chars/4): three fill the 4096 budget,
// the fourth would overflow — deterministic batches [m1,m2,m3] + [m4,m5].
// Teacher prose only (owner, 2026-09-28): learner messages are
// invisible to coverage, so the fixture is all-teacher.
2026-09-15 17:41:56 +03:00
chatStore . saveMessages (
chat . id ,
2026-09-28 22:37:18 +03:00
[ 1 , 2 , 3 , 4 , 5 ]. flatMap (( n ) => assistant ( `a ${ n } ` , [{ type : "text" , text : `topic ${ n } : ${ "detail " . repeat ( 700 ) }${ n } ` }])),
2026-09-15 17:41:56 +03:00
);
return { chatId : chat.id , record : chatStore.getChat ( chat . id ) ! };
}
describe ( "the chunked pipeline (extraction coverage)" , () => {
it ( "extracts uncovered messages in token-budgeted batches, checkpoints coverage per batch, and no-ops once covered" , async () => {
const { chatId , record } = chunkyChat ();
const ledgers = memoryLedgers ();
const views : string [] = [];
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-30 16:38:45 +03:00
// 4096 budget: the fixture is sized to split in two (the factory
2026-10-13 08:26:31 +03:00
// default again — halved back from the digest-era 8192, owner
// 2026-10-13).
2026-09-30 16:38:45 +03:00
getChunkTokens : () => 4096 ,
2026-09-15 17:41:56 +03:00
extractor : fauxEngine ([ findingsStep (( v ) => views . push ( v )), findingsStep (( v ) => views . push ( v ))]),
auditor : fauxEngine ([ passThroughAuditStep (), passThroughAuditStep ()]),
});
const stats = await pipeline . runExtraction ( chatId );
// 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" );
2026-09-28 22:37:18 +03:00
assert . ok ( views [ 0 ] ! . includes ( "topic 1:" ) && views [ 0 ] ! . includes ( "topic 3:" ), "teacher prose only, first three" );
2026-09-28 19:04:39 +03:00
assert . ok ( ! views [ 0 ] ! . includes ( "topic 4:" ), "the batch stops at the token budget" );
2026-09-28 22:37:18 +03:00
assert . ok ( views [ 1 ] ! . includes ( "topic 4:" ) && views [ 1 ] ! . includes ( "topic 5:" ));
2026-09-28 19:04:39 +03:00
assert . ok ( ! views [ 1 ] ! . includes ( "topic 3:" ));
2026-10-13 08:26:31 +03:00
assert . doesNotMatch ( views [ 0 ] ! , /### / , "no speaker headers — passages are TEACHER [id] lines" );
2026-09-28 16:54:44 +03:00
// The document header is the session title.
assert . ok ( views [ 0 ] ! . startsWith ( "# Chunky coverage fixture" ));
2026-10-13 08:26:31 +03:00
// MESSAGE-ID LABELS (2026-10-13): every passage carries its REAL
// message id — batch 1 holds a1-a3, batch 2 a4-a5, and the labels
// follow the split exactly.
assert . match ( views [ 0 ] ! , /^TEACHER \[a1\] topic 1:/m );
assert . match ( views [ 0 ] ! , /^TEACHER \[a3\] topic 3:/m );
assert . doesNotMatch ( views [ 0 ] ! , /TEACHER \[a4\]/ );
assert . match ( views [ 1 ] ! , /^TEACHER \[a4\] topic 4:/m );
assert . match ( views [ 1 ] ! , /^TEACHER \[a5\] topic 5:/m );
2026-09-15 17:41:56 +03:00
2026-09-28 19:22:58 +03:00
// Batch 1 merged the full findings (verdicts-only: the extractor's
2026-09-29 02:25:32 +03:00
// output always fully merges); batch 2's identical ids are a
// SEPARATE job's namespace (owner, 2026-09-29 — no mapping table)
// — its propositions merge again as fresh nodes. Cross-batch
// identity is the auditor's linkTo verdict, never the merger's.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 4 , chains : 2 });
2026-09-15 17:41:56 +03:00
// Per-batch coverage: every message checkpointed, nothing legacy.
const coverage = ledgers . readCoverage ();
2026-09-26 01:52:47 +03:00
assert . equal ( coverage . length , record . messages . length );
2026-09-15 17:41:56 +03:00
assert . equal ( ledgers . readCheckpoints (). length , 0 );
2026-09-26 01:52:47 +03:00
assert . equal ( coverageOf ( record . messages , coverage , chatId ). uncoveredCount , 0 );
2026-09-15 17:41:56 +03:00
// A second run is a NO-OP: nothing uncovered → no model calls, no
// new usage entry (no money spent on covered ground).
const before = views . length ;
const again = await pipeline . runExtraction ( chatId );
2026-09-30 03:22:52 +03:00
assert . deepEqual ( again , { nodes : 0 , chains : 0 });
2026-09-15 17:41:56 +03:00
assert . equal ( views . length , before );
2026-09-29 00:34:21 +03:00
// PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job
// (two), and the no-op run adds none.
assert . equal ( chatStore . getChat ( chatId ) ! . extractions ? . length , 2 , "one usage record per batch job; the no-op run persists none" );
2026-09-15 17:41:56 +03:00
});
it ( "aborting after a merged batch keeps it covered; the re-run extracts only the remainder" , async () => {
const { chatId , record } = chunkyChat ();
const ledgers = memoryLedgers ();
const views : string [] = [];
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-30 16:38:45 +03:00
// 4096 budget: the fixture is sized to split in two (the factory
2026-10-13 08:26:31 +03:00
// default again — halved back from the digest-era 8192, owner
// 2026-10-13).
2026-09-30 16:38:45 +03:00
getChunkTokens : () => 4096 ,
2026-09-15 17:41:56 +03:00
// Batch 1 submits and merges; batch 2's extractor call HANGS
// until the abort fires — the dock's abort button, mid-run.
extractor : fauxEngine ([
findingsStep (( v ) => views . push ( v )),
( _context , options ) =>
new Promise (( resolve ) => {
const sig = options ? . signal ;
if ( sig ? . aborted ) {
resolve ( fauxAssistantMessage ( "late" ));
return ;
}
sig ? . addEventListener ( "abort" , () => resolve ( fauxAssistantMessage ( "late" )), { once : true });
}),
]),
auditor : fauxEngine ([ passThroughAuditStep ()]),
});
const runPromise = pipeline . runExtraction ( chatId );
// The abort must land while batch 2's extractor hangs. The faux
// stream only resolves on abort, so wait for the batch-1 merge to
// show in coverage, then abort.
await new Promise < void >(( resolve ) => {
const check = setInterval (() => {
if ( ledgers . readCoverage (). length > 0 ) {
clearInterval ( check );
resolve ();
}
}, 5 );
});
assert . equal ( abortExtraction ( chatId ), true );
const stats = await runPromise ;
// Batch 1's emissions are kept and its messages stay covered.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 2 , chains : 1 });
2026-09-15 17:41:56 +03:00
assert . equal ( ledgers . readCoverage (). length , 3 , "batch 1 checkpointed its three messages" );
2026-09-26 01:52:47 +03:00
const cov = coverageOf ( record . messages , ledgers . readCoverage (), chatId );
2026-09-15 17:41:56 +03:00
assert . equal ( cov . uncoveredCount , 2 , "the in-flight batch's messages stay uncovered" );
// The re-run — the manual extract button — picks up ONLY the
// remainder: one batch, messages 4-5 alone in the prompt.
const reRunViews : string [] = [];
const finisher = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : fauxEngine ([ findingsStep (( v ) => reRunViews . push ( v ))]),
auditor : fauxEngine ([ passThroughAuditStep ()]),
});
await finisher . runExtraction ( chatId );
assert . equal ( reRunViews . length , 1 );
2026-09-28 22:37:18 +03:00
assert . ok ( reRunViews [ 0 ] ! . includes ( "topic 4:" ) && reRunViews [ 0 ] ! . includes ( "topic 5:" ));
assert . ok ( ! reRunViews [ 0 ] ! . includes ( "topic 1:" ), "the covered remainder never re-enters the prompt" );
2026-09-15 17:41:56 +03:00
assert . equal ( ledgers . readCoverage (). length , 5 , "the re-run closed the gap" );
});
it ( "a re-opened message re-extracts alone; a data-part attach never re-opens it" , async () => {
// A small session: one batch covers everything, so re-openings are
// the ONLY thing later runs can see.
const chat = chatStore . createChat ( "Re-open fixture" );
createdChats . push ( chat . id );
2026-09-28 22:37:18 +03:00
chatStore . saveMessages ( chat . id , [
... assistant ( "s1" , [{ type : "text" , text : "short topic one" }]),
... assistant ( "s2" , [{ type : "text" , text : "short topic two" }]),
]. flat ());
2026-09-15 17:41:56 +03:00
const chatId = chat . id ;
const ledgers = memoryLedgers ();
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : fauxEngine ([ findingsStep ()]),
auditor : fauxEngine ([ passThroughAuditStep ()]),
});
await pipeline . runExtraction ( chatId );
2026-09-26 01:52:47 +03:00
// Wave D: the record IS native history — coverage reads it directly.
2026-09-26 00:37:46 +03:00
const record = () => chatStore . getChat ( chatId ) ! . messages ;
2026-09-26 01:52:47 +03:00
const messages = () => record ();
2026-09-15 17:41:56 +03:00
assert . equal ( coverageOf ( messages (), ledgers . readCoverage (), chatId ). uncoveredCount , 0 );
2026-09-26 00:37:46 +03:00
// Data parts are invisible to the extraction view — a plan-block
// marker append (whose data part splices into the projected view)
// does NOT re-open a covered message.
chatStore . saveMessages ( chatId , [
... record (),
newPlanBlockMessage ( "call_s2" , { kind : "full" , at : "2026-09-26T00:00:00.000Z" , current : null , nodes : [], edges : [] }),
]);
2026-09-15 17:41:56 +03:00
const afterAttach = coverageOf ( messages (), ledgers . readCoverage (), chatId );
assert . equal ( afterAttach . uncoveredCount , 0 , "data parts don't change the fingerprint" );
2026-09-28 22:37:18 +03:00
// A teacher-prose edit changes the message's view → exactly that
// message re-opens, and the next run extracts it alone. (Under the
// 2026-09-28 ruling only TEACHER prose can re-open anything.)
2026-09-26 00:37:46 +03:00
const mutated = record (). map (( m ) =>
( m as { id? : string }). id === "s1"
? ({ ... m , content : [...( m as { content : unknown [] }). content , { type : "text" , text : "the answer merged in late" }] } as never )
: m ,
);
2026-09-15 17:41:56 +03:00
chatStore . saveMessages ( chatId , mutated );
const afterMerge = coverageOf ( messages (), ledgers . readCoverage (), chatId );
assert . equal ( afterMerge . uncoveredCount , 1 , "only the mutated message re-opens" );
const reRunViews : string [] = [];
const reOpener = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : fauxEngine ([ findingsStep (( v ) => reRunViews . push ( v ))]),
auditor : fauxEngine ([ passThroughAuditStep ()]),
});
await reOpener . runExtraction ( chatId );
assert . equal ( reRunViews . length , 1 , "one batch: the single re-opened message" );
2026-09-28 22:37:18 +03:00
assert . ok ( reRunViews [ 0 ] ! . includes ( "the answer merged in late" ) && ! reRunViews [ 0 ] ! . includes ( "short topic two" ));
assert . ok ( reRunViews [ 0 ] ! . includes ( "short topic one" ), "the re-opened message renders its full prose" );
2026-09-15 17:41:56 +03:00
assert . equal ( coverageOf ( messages (), ledgers . readCoverage (), chatId ). uncoveredCount , 0 , "re-covered" );
});
});
2026-09-23 02:41:04 +03:00
// ─── session deletion mid-run (wave 6) ─────────────────────────────────────
2026-09-29 00:34:21 +03:00
// ─── per-chunk parallel jobs (owner, 2026-09-28) ─────────────────────────────
describe ( "per-chunk parallel jobs (owner, 2026-09-28)" , () => {
const sleep = ( ms : number ) => new Promise < void >(( resolve ) => setTimeout ( resolve , ms ));
/** 8 single-line chunks: one line per chunk under a tiny budget — a
* fan-out wide enough to see both the 3-slot semaphore and the queue. */
const wideText = () =>
Array . from ({ length : 8 }, ( _ , i ) => `note ${ i } : ${ "x" . repeat ( 90 ) } ` ). join ( "\n" );
2026-09-30 17:44:37 +03:00
it ( "one job per chunk, SERIALIZED per source: the registry shows one live row at a time, and a source-wide abort ends the run" , async () => {
2026-09-29 00:34:21 +03:00
const ledgers = memoryLedgers ();
// The extractor call hangs until its ABORT — the observation window:
2026-09-30 17:44:37 +03:00
// the parked chunk-1 job must be the ONLY live row (later chunks
// mint only when their turn comes, digest order, owner 2026-09-30).
2026-09-29 00:34:21 +03:00
const abortingStep : FauxResponseStep = ( _context , options ) =>
new Promise (( _resolve , reject ) => {
const sig = options ? . signal ;
const die = () => {
const err = new Error ( "aborted" );
err . name = "AbortError" ;
reject ( err );
};
if ( sig ? . aborted ) die ();
else sig ? . addEventListener ( "abort" , die , { once : true });
});
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
engineFor : ( role ) =>
2026-09-29 01:16:32 +03:00
role === "extractor" ? fauxEngine ([ abortingStep ]) : submitModel ( "submit_review" , { links : [] }),
2026-09-29 00:34:21 +03:00
getChunkTokens : () => 20 ,
});
const runPromise = pipeline . importNotes ({ filePath : "/vault/parallel.md" , text : wideText () });
const srcId = extractionStatuses (). find (( r ) => r . chatId . startsWith ( "src_" )) ! . chatId ;
2026-09-30 17:44:37 +03:00
await until (() => extractionStatuses (). some (( r ) => r . chatId === srcId && r . phase === "extracting" ), 500 );
2026-09-29 00:34:21 +03:00
const rows = extractionStatuses (). filter (( r ) => r . chatId === srcId );
2026-09-30 17:44:37 +03:00
assert . equal ( rows . length , 1 , "one live row — the source's chunks run strictly in order" );
assert . equal ( rows [ 0 ] ! . phase , "extracting" );
assert . deepEqual ( rows [ 0 ] ! . part , { i : 1 , n : 8 }, "the row carries its part" );
2026-09-29 00:34:21 +03:00
2026-09-30 17:44:37 +03:00
// SOURCE-WIDE abort: the parked job dies and the run ENDS — no
// later chunk ever mints (fresh rows would hang un-aborted).
2026-09-29 00:34:21 +03:00
assert . equal ( abortExtraction ( srcId ), true );
2026-10-23 04:43:56 +03:00
await assert . rejects ( runPromise , ( error ) => error instanceof NotesImportError && error . outcome === "aborted" );
2026-09-29 00:34:21 +03:00
assert . equal ( extractionStatuses (). filter (( r ) => r . chatId === srcId ). length , 0 , "all rows leave the registry" );
assert . equal ( ledgers . readNodes (). length , 0 , "nothing merged" );
2026-09-30 17:44:37 +03:00
// Per-job usage: the ONE minted job persisted its (failed) spend.
2026-09-29 00:34:21 +03:00
const source = ledgers . readSources (). find (( src ) => src . id === srcId ) ! ;
2026-09-30 17:44:37 +03:00
assert . equal ( source . extractions ? . length , 1 , "only the aborted job bills — later chunks never ran" );
2026-09-29 00:34:21 +03:00
assert . ok ( source . extractions ! . every (( entry ) => entry . stats === undefined ), "no job merged — no stats" );
});
it ( "parallel extracts, serialized audits: extractor concurrency peaks at the cap, the auditor NEVER overlaps" , async () => {
const ledgers = memoryLedgers ();
let extractInFlight = 0 ;
let extractPeak = 0 ;
let auditInFlight = 0 ;
let auditPeak = 0 ;
// Hand-built submit messages (findingsStep()/passThroughAuditStep()
// are step FACTORIES — the slow wrapper must return messages).
const findingsMessage = fauxAssistantMessage (
[ fauxToolCall ( "submit_findings" , FINDINGS as unknown as ToolCall [ "arguments" ], { id : "findings-call" })],
{ stopReason : "toolUse" },
);
const reviewMessage = fauxAssistantMessage (
2026-09-29 01:16:32 +03:00
[ fauxToolCall ( "submit_review" , { links : [] } as unknown as ToolCall [ "arguments" ], { id : "review-call" })],
2026-09-29 00:34:21 +03:00
{ stopReason : "toolUse" },
);
const slowExtract : FauxResponseStep = async () => {
extractInFlight ++ ;
extractPeak = Math . max ( extractPeak , extractInFlight );
await sleep ( 5 );
extractInFlight -- ;
return findingsMessage ;
};
const slowAudit : FauxResponseStep = async () => {
auditInFlight ++ ;
auditPeak = Math . max ( auditPeak , auditInFlight );
await sleep ( 5 );
auditInFlight -- ;
return reviewMessage ;
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-30 16:38:45 +03:00
engineFor : ( role ) =>
2026-09-30 17:44:37 +03:00
role === "extractor" ? fauxEngine ([ slowExtract ]) : fauxEngine ([ slowAudit ]),
2026-09-29 00:34:21 +03:00
getChunkTokens : () => 20 ,
});
const stats = await pipeline . importNotes ({ filePath : "/vault/parallel-2.md" , text : wideText () });
2026-09-30 17:44:37 +03:00
// SERIALIZED PER SOURCE (owner, 2026-09-30 evening): one source's
// chunks run strictly in order — each extractor continues the
// digest of every earlier merge, so the peak within one source is
// ONE concurrent extract. (Cross-source runs still parallelize;
// the 3-permit semaphore still caps those.)
assert . equal ( extractPeak , 1 , "one source, one extract at a time — strict order" );
2026-09-29 00:34:21 +03:00
assert . equal ( auditPeak , 1 , "audits serialize behind the gate — never two at once" );
2026-09-29 02:25:32 +03:00
// Identical findings in every chunk, one merge PER chunk (jobs are
// independent — owner, 2026-09-29): 8 chunks × 2 propositions,
2026-09-30 17:44:37 +03:00
// 8 × 1 chain. The auditor's links, not the merger, dedupe identity.
2026-09-30 03:22:52 +03:00
assert . deepEqual ( stats , { nodes : 16 , chains : 8 });
2026-09-29 02:25:32 +03:00
assert . equal ( ledgers . readNodes (). length , 16 );
2026-09-29 00:34:21 +03:00
});
});
2026-09-23 02:41:04 +03:00
describe ( "the session-deletion guard (chat deleted mid-run)" , () => {
it ( "a run whose chat dies mid-extraction aborts at the merge with nothing merged, checkpointed, or billed" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// The chat record is deleted DURING the audit model call — the worst
// window: findings already spent, the merge next.
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , FINDINGS ),
2026-09-29 01:16:32 +03:00
auditor : submitModel ( "submit_review" , { links : [] }, () => chatStore . deleteChat ( chatId )),
2026-09-23 02:41:04 +03:00
});
const stats = await pipeline . runExtraction ( chatId );
// The abort rides the existing abort path: info, not error; zero
// emissions (stats present but empty — nothing merged).
assert . ok ( stats );
assert . equal ( stats . nodes , 0 );
2026-09-30 03:22:52 +03:00
assert . equal ( stats . chains , 0 );
2026-09-23 02:41:04 +03:00
// Nothing merged, nothing checkpointed — the deleted chat leaves no
// trace for a later snapshot to resurrect.
assert . equal ( ledgers . readNodes (). length , 0 );
2026-09-30 03:22:52 +03:00
assert . equal ( ledgers . readChains (). length , 0 );
2026-09-23 02:41:04 +03:00
assert . equal ( ledgers . readCoverage (). length , 0 );
assert . equal ( ledgers . readCheckpoints (). length , 0 );
// The run is off the registry (the finally always cleans up).
assert . equal ( extractionStatuses (). some (( run ) => run . chatId === chatId ), false );
// And its partial spend died with the chat — no usage row on a ghost.
assert . equal ( chatStore . getChat ( chatId ), null );
});
});
2026-09-30 16:38:45 +03:00
2026-09-30 17:44:37 +03:00
// ─── digest-aware serialized extraction + chain simplification (owner, 2026-09-30 evening) ────
2026-09-30 16:38:45 +03:00
2026-09-30 17:44:37 +03:00
describe ( "digest-aware serialized extraction (the extractor is the assembler)" , () => {
it ( "the system prompt is goal-first: core ideas, foundations direction, build-on-the-digest doctrine" , () => {
assert . match ( EXTRACTION_CORE , /THE GOAL: uncover the source's CORE IDEAS and show how everything derives from the foundations/ );
assert . match ( EXTRACTION_CORE , /JOINTLY entail a conclusion/ );
assert . match ( EXTRACTION_CORE , /[Ee]ntailment is the\s+only link that earns a\s+chain/ );
assert . match ( EXTRACTION_CORE , /examples,\s+notation glosses, and restatements that no argument needs are not core ideas/ );
assert . match ( EXTRACTION_CORE , /build\s+on it\. Reference an existing idea by its id/ );
assert . match ( EXTRACTION_CORE , /OR existing ids from THE GRAPH SO FAR, verbatim/ );
// Each front door = its preamble + the shared core; the preambles
// name the continuing-source posture.
assert . ok ( EXTRACTOR_INSTRUCTIONS . includes ( EXTRACTION_CORE ));
assert . ok ( NOTES_EXTRACTOR_INSTRUCTIONS . includes ( EXTRACTION_CORE ));
assert . match ( SESSION_PREAMBLE , /building on everything already extracted from this source/ );
assert . match ( NOTES_PREAMBLE , /building on everything already extracted from this source/ );
2026-09-30 16:38:45 +03:00
});
2026-09-30 17:44:37 +03:00
it ( "sourceDigest renders the same-source truth (ids verbatim) and stays empty for a barren source" , () => {
const seed = ( id : string , name : string , chatId : string ) : GraphNode => ({
id ,
name ,
statement : `statement of ${ name } ` ,
aliases : [],
createdAt : "2026-09-30T00:00:00.000Z" ,
provenance : [{ chatId , messageId : "" }],
});
const chain = ( id : string , premises : string [], conclusion : string , chatId : string ) : GraphChain => ({
id ,
premises ,
conclusion ,
createdAt : "2026-09-30T00:00:01.000Z" ,
provenance : [{ chatId , messageId : "" }],
});
2026-09-30 16:38:45 +03:00
const graph = {
2026-09-30 17:44:37 +03:00
nodes : [ seed ( "n_a" , "Self-ownership" , "src_1" ), seed ( "n_x" , "Foreign key" , "src_2" )],
chains : [ chain ( "arg_1" , [ "n_a" ], "n_x" , "src_2" )], // same NAME, other source — excluded
sessions : {},
sources : {},
} as unknown as Parameters < typeof sourceDigest >[ 0 ];
assert . equal ( sourceDigest ( graph , "src_9" ), "" , "a source owning nothing runs bare — no digest block" );
const digest = sourceDigest ( graph , "src_1" );
assert . match ( digest , /THE GRAPH SO FAR/ );
assert . match ( digest , /n_a \| Self-ownership \| statement of Self-ownership/ );
assert . doesNotMatch ( digest , /Foreign key/ , "other sources' material never leaks into the digest" );
assert . doesNotMatch ( digest , /DERIVATIONS ALREADY RECORDED/ , "no same-source chains → no chain block" );
2026-09-30 16:38:45 +03:00
});
2026-09-30 17:44:37 +03:00
it ( "a seeded digest rides the extractor's view: mixed-namespace chains reference existing ids VERBATIM" , async () => {
2026-09-30 16:38:45 +03:00
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const seed = ( id : string , name : string ) : GraphNode => ({
id ,
name ,
statement : `statement of ${ name } ` ,
aliases : [],
createdAt : "2026-09-30T00:00:00.000Z" ,
provenance : [{ chatId , messageId : "m0" }],
});
ledgers . appendNode ( seed ( "n_a" , "Self-ownership" ));
const FINDINGS_B : SessionFindings = {
2026-10-13 08:26:31 +03:00
propositions : [{ id : "p1" , name : "Appropriation axiom" , statement : "c" , aliases : [], messageId : "a1" }],
2026-09-30 17:44:37 +03:00
chains : [{ premises : [ "n_a" , "p1" ], conclusion : "p1" , }]. slice ( 0 , 0 ), // chains live in the engine below
2026-09-30 16:38:45 +03:00
};
2026-09-30 17:44:37 +03:00
const views : string [] = [];
const extractor = fauxEngine ([
2026-09-30 16:38:45 +03:00
( context ) => {
2026-09-30 17:44:37 +03:00
views . push ( capturedView ( context ));
const submit = {
propositions : FINDINGS_B.propositions ,
chains : [{ premises : [ "n_a" , "p1" ], conclusion : "p1b" }],
2026-09-30 16:38:45 +03:00
};
2026-09-30 17:44:37 +03:00
// conclusion p1b must exist — real submit:
2026-10-13 08:26:31 +03:00
submit . propositions . push ({ id : "p1b" , name : "Property rights" , statement : "d" , aliases : [], messageId : "a2" });
2026-09-30 17:44:37 +03:00
return fauxAssistantMessage ([ fauxToolCall ( "submit_findings" , submit as unknown as ToolCall [ "arguments" ], { id : "f1" })], { stopReason : "toolUse" });
2026-09-30 16:38:45 +03:00
},
]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
2026-09-30 17:44:37 +03:00
extractor ,
2026-09-30 16:38:45 +03:00
auditor : submitModel ( "submit_review" , { links : [] }),
});
const stats = await pipeline . runExtraction ( chatId );
2026-09-30 17:44:37 +03:00
// The extractor's view LED with the digest (existing id verbatim).
assert . match ( views [ 0 ] ! , /THE GRAPH SO FAR/ );
assert . match ( views [ 0 ] ! , /n_a \| Self-ownership/ );
// Mixed namespace resolved: n_a passes through verbatim; p1/p1b
// resolve through the job-local map.
assert . deepEqual ( stats , { nodes : 2 , chains : 1 });
2026-09-30 16:38:45 +03:00
const stored = ledgers . readChains ();
assert . equal ( stored . length , 1 );
2026-09-30 17:44:37 +03:00
const byName = new Map ( ledgers . readNodes (). map (( n ) => [ n . name , n . id ]));
assert . deepEqual ( stored [ 0 ] ! . premises , [ "n_a" , byName . get ( "Appropriation axiom" )]);
assert . equal ( stored [ 0 ] ! . conclusion , byName . get ( "Property rights" ));
});
it ( "a chain endpoint that resolves NOWHERE is refused with the problem and the retry merges (error-back)" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
const badThenGood = fauxEngine ([
() =>
fauxAssistantMessage (
[
fauxToolCall (
"submit_findings" ,
{ propositions : [], chains : [{ premises : [ "n_nope" ], conclusion : "also_nope" }] } as unknown as ToolCall [ "arguments" ],
{ id : "bad" },
),
],
{ stopReason : "toolUse" },
),
() =>
fauxAssistantMessage (
[
fauxToolCall (
"submit_findings" ,
{
2026-10-13 08:26:31 +03:00
propositions : [{ id : "p1" , name : "Axiom" , statement : "s" , aliases : [], messageId : "a1" }],
2026-09-30 17:44:37 +03:00
chains : [],
} as unknown as ToolCall [ "arguments" ],
{ id : "good" },
),
],
{ stopReason : "toolUse" },
),
]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : badThenGood ,
auditor : submitModel ( "submit_review" , { links : [] }),
});
const stats = await pipeline . runExtraction ( chatId );
assert . deepEqual ( stats , { nodes : 1 , chains : 0 }, "the refused attempt left nothing; the retry merged" );
});
it ( "batches serialize: chunk 2's extractor starts only after chunk 1's audit ran (digest order, owner 2026-09-30 evening)" , async () => {
const { chatId } = chunkyChat (); // 2 deterministic batches at the 4096 override
const ledgers = memoryLedgers ();
const events : string [] = [];
let extractCalls = 0 ;
let auditCalls = 0 ;
// One step per engine call (faux engines consume steps): two batches.
2026-10-13 08:26:31 +03:00
const extractStep = () : FauxResponseStep => ( context ) => {
2026-09-30 17:44:37 +03:00
extractCalls ++ ;
events . push ( `extract ${ extractCalls } ` );
2026-10-13 08:26:31 +03:00
// The anchor is copied from the CURRENT stretch's first TEACHER
// label (a1 in batch 1, a4 in batch 2) — batch-local by construction.
const anchor = /TEACHER \[([^\]]+)\]/ . exec ( capturedView ( context )) ? .[ 1 ] ?? "a1" ;
const submit = {
... FINDINGS ,
propositions : FINDINGS.propositions.map (( p ) => ({ ... p , messageId : anchor })),
};
2026-09-30 17:44:37 +03:00
return fauxAssistantMessage (
2026-10-13 08:26:31 +03:00
[ fauxToolCall ( "submit_findings" , submit as unknown as ToolCall [ "arguments" ], { id : `f ${ extractCalls } ` })],
2026-09-30 17:44:37 +03:00
{ stopReason : "toolUse" },
);
};
const auditStep = () : FauxResponseStep => () => {
auditCalls ++ ;
events . push ( `audit ${ auditCalls } ` );
return fauxAssistantMessage (
[ fauxToolCall ( "submit_review" , { links : [] } as unknown as ToolCall [ "arguments" ], { id : `r ${ auditCalls } ` })],
{ stopReason : "toolUse" },
);
};
const extractor = fauxEngine ([ extractStep (), extractStep ()]);
const auditor = fauxEngine ([ auditStep (), auditStep ()]);
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor ,
auditor ,
2026-10-13 08:26:31 +03:00
getChunkTokens : () => 4096 , // two batches over the chunky fixture (the factory default, 4096 — halved back 2026-10-13)
2026-09-30 17:44:37 +03:00
});
const stats = await pipeline . runExtraction ( chatId );
assert . deepEqual ( events , [ "extract1" , "audit1" , "extract2" , "audit2" ], "strict per-source order: extract → audit → merge → NEXT extract" );
assert . equal ( stats . nodes , 4 , "both batches merged (2 propositions each)" );
});
});
describe ( "chain simplification (the merger's maintenance pass, owner 2026-09-30 evening)" , () => {
const chain = ( id : string , premises : string [], conclusion : string , createdAt : string ) : GraphChain => ({
id ,
premises ,
conclusion ,
createdAt ,
provenance : [{ chatId : "src_1" , messageId : "" }],
});
it ( "the owner example: [A,B]⇒X + [X,C]⇒Y make [A,B,C]⇒Y redundant (ancestry-cover)" , () => {
const kept = simplifyChains ([
chain ( "arg_3" , [ "n_a" , "n_b" , "n_c" ], "n_y" , "2026-09-30T03:00:00.000Z" ),
chain ( "arg_1" , [ "n_a" , "n_b" ], "n_x" , "2026-09-30T01:00:00.000Z" ),
chain ( "arg_2" , [ "n_x" , "n_c" ], "n_y" , "2026-09-30T02:00:00.000Z" ),
]);
assert . deepEqual ( kept . map (( c ) => c . id ). sort (), [ "arg_1" , "arg_2" ]);
});
it ( "prefer-fewest-premises: a strict premise-superset of a kept chain drops" , () => {
const kept = simplifyChains ([
chain ( "arg_1" , [ "n_a" ], "n_z" , "2026-09-30T01:00:00.000Z" ),
chain ( "arg_2" , [ "n_a" , "n_b" ], "n_z" , "2026-09-30T02:00:00.000Z" ),
]);
assert . deepEqual ( kept . map (( c ) => c . id ), [ "arg_1" ]);
});
it ( "genuinely different routes to one conclusion stand; a superset route yields to the lean one" , () => {
const kept = simplifyChains ([
chain ( "arg_1" , [ "n_a" ], "n_y" , "2026-09-30T01:00:00.000Z" ),
chain ( "arg_2" , [ "n_b" ], "n_y" , "2026-09-30T02:00:00.000Z" ),
chain ( "arg_3" , [ "n_c" , "n_d" ], "n_y" , "2026-09-30T03:00:00.000Z" ),
chain ( "arg_4" , [ "n_a" , "n_c" ], "n_y" , "2026-09-30T04:00:00.000Z" ),
]);
// arg_3's premises are covered by NO kept chain — an independent
// route, it stands. arg_4 is a strict premise-superset of arg_1's
// route (n_a already suffices) — the lean derivation wins.
assert . deepEqual ( kept . map (( c ) => c . id ). sort (), [ "arg_1" , "arg_2" , "arg_3" ]);
});
it ( "ancestry reaches transitively: [W]⇒A chains through [A,B]⇒X" , () => {
const kept = simplifyChains ([
chain ( "arg_1" , [ "n_w" ], "n_a" , "2026-09-30T01:00:00.000Z" ),
chain ( "arg_2" , [ "n_a" , "n_b" ], "n_x" , "2026-09-30T02:00:00.000Z" ),
chain ( "arg_3" , [ "n_x" , "n_c" ], "n_y" , "2026-09-30T03:00:00.000Z" ),
chain ( "arg_4" , [ "n_w" , "n_b" , "n_c" ], "n_y" , "2026-09-30T04:00:00.000Z" ),
]);
assert . deepEqual ( kept . map (( c ) => c . id ). sort (), [ "arg_1" , "arg_2" , "arg_3" ]);
});
it ( "same-key provenance lines drop TOGETHER (a key is kept or dropped as a whole)" , () => {
const kept = simplifyChains ([
chain ( "arg_1" , [ "n_a" ], "n_z" , "2026-09-30T01:00:00.000Z" ),
{ ... chain ( "arg_1" , [ "n_a" ], "n_z" , "2026-09-30T01:00:00.000Z" ), provenance : [{ chatId : "src_2" , messageId : "" }] },
chain ( "arg_2" , [ "n_a" , "n_b" ], "n_z" , "2026-09-30T02:00:00.000Z" ),
{ ... chain ( "arg_2" , [ "n_a" , "n_b" ], "n_z" , "2026-09-30T02:00:00.000Z" ), provenance : [{ chatId : "src_2" , messageId : "" }] },
]);
assert . deepEqual ( kept . map (( c ) => c . id ), [ "arg_1" , "arg_1" ], "the superset key's every line went, the kept key's every line stayed" );
});
it ( "the merge applies it live: a redundant arrival is skipped silently, stats count appends and drops" , async () => {
const { chatId } = fixtureChat ();
const ledgers = memoryLedgers ();
// Seed [A,B]⇒X and [X,C]⇒Y as an earlier source's chains.
const seedNode = ( id : string , name : string ) : GraphNode => ({
id ,
name ,
statement : `statement of ${ name } ` ,
aliases : [],
createdAt : "2026-09-30T00:00:00.000Z" ,
provenance : [{ chatId , messageId : "m0" }],
});
for ( const [ id , name ] of [
[ "n_a" , "A" ],
[ "n_b" , "B" ],
[ "n_c" , "C" ],
[ "n_x" , "X" ],
[ "n_y" , "Y" ],
] as const )
ledgers . appendNode ( seedNode ( id , name ));
ledgers . appendChain ( chain ( "arg_1" , [ "n_a" , "n_b" ], "n_x" , "2026-09-30T01:00:00.000Z" ));
ledgers . appendChain ( chain ( "arg_2" , [ "n_x" , "n_c" ], "n_y" , "2026-09-30T02:00:00.000Z" ));
// The chat re-asserts the full derivation [A,B,C]⇒Y (all-existing
// ids, digest references) — redundant given what stands.
const redundant : SessionFindings = {
propositions : [],
chains : [{ premises : [ "n_a" , "n_b" , "n_c" ], conclusion : "n_y" }],
};
const pipeline = createExtractionPipeline ({
chatStore ,
graphStore : ledgers ,
extractor : submitModel ( "submit_findings" , redundant ),
auditor : submitModel ( "submit_review" , { links : [] }),
});
const stats = await pipeline . runExtraction ( chatId );
assert . equal ( stats . chains , 1 , "the append happened (stats count activity)" );
assert . equal ( stats . droppedChains , 1 , "the simplification pass then removed it (maintenance)" );
const stored = ledgers . readChains ();
assert . equal ( stored . length , 2 , "the ledger is back to the two lean chains" );
2026-09-30 16:38:45 +03:00
});
});
2026-10-23 04:43:56 +03:00
describe ( "notes import completion and retry reliability" , () => {
const input = { filePath : "reliability.md" , text : NOTES_TEXT };
const make = ( graphStore : GraphStore , extractor = submitModel ( "submit_findings" , NOTES_FINDINGS )) =>
createExtractionPipeline ({ chatStore , graphStore , extractor , auditor : submitModel ( "submit_review" , NOTES_VERDICT ) });
const incomplete = ( outcome : NotesImportError [ "outcome" ], nodes : number ) => ( error : unknown ) => {
assert . ok ( error instanceof NotesImportError );
assert . equal ( error . outcome , outcome );
assert . equal ( error . partialStats . nodes , nodes );
return true ;
};
it ( "failure followed by an unchanged retry really extracts and preserves both usage records" , async () => {
const ledgers = memoryLedgers ();
await assert . rejects ( make ( ledgers , silentModel ()). importNotes ( input ), incomplete ( "failed" , 0 ));
assert . deepEqual ( await make ( ledgers ). importNotes ( input ), { nodes : 1 , chains : 0 });
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
});
it ( "zero new nodes and an empty file are successful, but the readiness gate still applies" , async () => {
const ledgers = memoryLedgers ();
assert . deepEqual ( await make ( ledgers , submitModel ( "submit_findings" , { propositions : [], chains : [] })). importNotes ( input ), { nodes : 0 , chains : 0 });
assert . deepEqual ( await make ( ledgers ). importNotes ({ ... input , text : " \n" }), { nodes : 0 , chains : 0 });
await assert . rejects ( createExtractionPipeline ({ chatStore , graphStore : ledgers }). importNotes ({ ... input , text : "" }), ModelsNotConfiguredError );
await assert . rejects ( createExtractionPipeline ({ chatStore , graphStore : ledgers , extractor : submitModel ( "submit_findings" , NOTES_FINDINGS ), auditor : submitModel ( "submit_review" , NOTES_VERDICT ), providerKeyMissing : () => providerKeyMissing ( "openai" , [ "cheap" ]) }). importNotes ( input ), ProviderKeyMissingError );
});
it ( "isolates an ordinary failed chunk, reports partial, and keeps earlier/later merges and usage" , async () => {
const ledgers = memoryLedgers ();
let calls = 0 ;
const pipeline = createExtractionPipeline ({
chatStore , graphStore : ledgers , getChunkTokens : () => 1 ,
engineFor : ( role ) => role === "auditor" ? submitModel ( "submit_review" , NOTES_VERDICT ) : fauxEngine ([() => {
calls ++ ;
if ( calls === 2 ) throw new Error ( "synthetic failed chunk" );
return fauxAssistantMessage ([ fauxToolCall ( "submit_findings" , NOTES_FINDINGS as unknown as ToolCall [ "arguments" ])], { stopReason : "toolUse" });
}]),
});
await assert . rejects ( pipeline . importNotes ({ ... input , text : "first chunk\nsecond chunk\nthird chunk" }), incomplete ( "partial" , 2 ));
assert . equal ( calls , 3 );
assert . equal ( ledgers . readNodes (). length , 2 );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 3 );
});
it ( "an abort after an earlier merge stops later chunks without losing graph or billed usage" , async () => {
const ledgers = memoryLedgers ();
let calls = 0 ;
const pipeline = createExtractionPipeline ({
chatStore , graphStore : ledgers , getChunkTokens : () => 1 ,
engineFor : ( role ) => role === "auditor" ? submitModel ( "submit_review" , NOTES_VERDICT ) : submitModel ( "submit_findings" , NOTES_FINDINGS , () => {
calls ++ ;
if ( calls === 2 ) abortExtraction ( ledgers . readSources ()[ 0 ] ! . id );
}),
});
await assert . rejects ( pipeline . importNotes ({ ... input , text : "first chunk\nsecond chunk\nthird chunk" }), incomplete ( "aborted" , 1 ));
assert . equal ( calls , 2 );
assert . equal ( ledgers . readNodes (). length , 1 );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
});
for ( const abortAt of [ 1 , 2 ]) it ( `abort in chunk ${ abortAt } survives a usage-persistence error and stops later chunks` , async () => {
const ledgers = memoryLedgers ();
let calls = 0 ;
let cancelled = false ;
let usageFailures = 0 ;
const graphStore : GraphStore = { ... ledgers , appendSourceUsage : ( id , entry ) => {
if ( cancelled ) { usageFailures ++ ; throw new Error ( "synthetic usage persistence failure during abort" ); }
ledgers . appendSourceUsage ( id , entry );
} };
const pipeline = createExtractionPipeline ({
chatStore , graphStore , getChunkTokens : () => 1 ,
engineFor : ( role ) => role === "auditor" ? submitModel ( "submit_review" , NOTES_VERDICT ) : submitModel ( "submit_findings" , NOTES_FINDINGS , () => {
calls ++ ;
if ( calls === abortAt ) {
cancelled = true ;
assert . equal ( abortExtraction ( ledgers . readSources ()[ 0 ] ! . id ), true );
}
}),
});
const wholeFile = { ... input , text : "first chunk\nsecond chunk\nthird chunk" };
await assert . rejects ( pipeline . importNotes ( wholeFile ), ( error ) => {
incomplete ( "aborted" , abortAt - 1 )( error );
assert . match (( error as NotesImportError ). message , /synthetic usage persistence failure during abort/ );
return true ;
});
assert . equal ( calls , abortAt , "no later extractor runs with a fresh controller after cancellation" );
assert . equal ( usageFailures , 1 , "the aborted chunk attempted to persist usage and surfaced its failure" );
assert . equal ( ledgers . readNodes (). length , abortAt - 1 , "earlier graph merges retained" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , abortAt - 1 , "earlier usage retained" );
assert . equal ( extractionStatuses (). length , 0 , "cancelled job row removed" );
assert . deepEqual ( await make ( ledgers ). importNotes ( wholeFile ), { nodes : 1 , chains : 0 }, "source exclusion released for an explicit unchanged whole-file retry" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , abortAt );
});
it ( "a usage-persistence error without an aborted signal remains an isolated failure and later chunks run" , async () => {
const ledgers = memoryLedgers ();
let calls = 0 ;
const graphStore : GraphStore = { ... ledgers , appendSourceUsage : ( id , entry ) => {
if ( calls === 1 ) throw new Error ( "synthetic isolated usage failure" );
ledgers . appendSourceUsage ( id , entry );
} };
const pipeline = createExtractionPipeline ({
chatStore , graphStore , getChunkTokens : () => 1 ,
engineFor : ( role ) => role === "auditor" ? submitModel ( "submit_review" , NOTES_VERDICT ) : submitModel ( "submit_findings" , NOTES_FINDINGS , () => { calls ++ ; }),
});
await assert . rejects ( pipeline . importNotes ({ ... input , text : "first chunk\nsecond chunk\nthird chunk" }), ( error ) => {
incomplete ( "partial" , 3 )( error );
assert . match (( error as NotesImportError ). message , /synthetic isolated usage failure/ );
return true ;
});
assert . equal ( calls , 3 , "ordinary cleanup failure does not imply cancellation" );
assert . equal ( ledgers . readNodes (). length , 3 );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
});
it ( "overlapping identical or different contents are busy BEFORE source metadata changes, across pipeline instances" , async () => {
const ledgers = memoryLedgers ();
let release !: () => void ;
const gate = new Promise < void >(( resolve ) => { release = resolve ; });
let upserts = 0 ;
const tracked = { ... ledgers , upsertSource : ( source : NotesSource ) => { upserts ++ ; ledgers . upsertSource ( source ); } };
const first = make ( tracked , gatedSubmitModel ( "submit_findings" , NOTES_FINDINGS , gate )). importNotes ( input );
const sourceBefore = structuredClone ( ledgers . readSources ());
try {
await assert . rejects ( make ( tracked ). importNotes ( input ), incomplete ( "busy" , 0 ));
await assert . rejects ( make ( tracked ). importNotes ({ ... input , text : "changed" }), incomplete ( "busy" , 0 ));
assert . equal ( upserts , 1 , "busy calls cannot even attempt source metadata writes" );
assert . deepEqual ( ledgers . readSources (), sourceBefore );
} finally { release (); }
await first ;
assert . deepEqual ( await make ( ledgers ). importNotes ( input ), { nodes : 1 , chains : 0 }, "lock released after lifecycle" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
});
it ( "a fulfilled zero-node chunk plus a failed chunk is partial, not failed or completed" , async () => {
const ledgers = memoryLedgers ();
let calls = 0 ;
const pipeline = createExtractionPipeline ({
chatStore , graphStore : ledgers , getChunkTokens : () => 1 ,
engineFor : ( role ) => role === "auditor" ? submitModel ( "submit_review" , NOTES_VERDICT ) : fauxEngine ([() => {
if ( ++ calls === 2 ) throw new Error ( "synthetic failed chunk" );
return fauxAssistantMessage ([ fauxToolCall ( "submit_findings" , { propositions : [], chains : [] })], { stopReason : "toolUse" });
}]),
});
await assert . rejects ( pipeline . importNotes ({ ... input , text : "first chunk\nsecond chunk" }), incomplete ( "partial" , 0 ));
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
});
it ( "a merger failure after a node append is incomplete without rolling back that write" , async () => {
const ledgers = memoryLedgers ();
const broken = { ... ledgers , appendCheckpoint : () => { throw new Error ( "synthetic checkpoint failure after node append" ); } };
await assert . rejects ( make ( broken ). importNotes ( input ), incomplete ( "failed" , 0 ));
assert . equal ( ledgers . readNodes (). length , 1 , "already appended graph write retained even though merge never returned stats" );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 1 );
});
for ( const seam of [ "layout" , "usage" ] as const ) it ( `a post-merge ${ seam } failure is not false success and reports committed stats` , async () => {
const ledgers = memoryLedgers ();
const originalUsage = ledgers . appendSourceUsage ;
let failed = false ;
const broken : GraphStore = { ... ledgers ,
...( seam === "layout" ? { writePositions : () => { throw new Error ( "synthetic layout failure" ); } } : {
appendSourceUsage : ( id , entry ) => { if ( ! failed ) { failed = true ; throw new Error ( "synthetic usage failure" ); } originalUsage ( id , entry ); },
}),
};
await assert . rejects ( make ( broken ). importNotes ( input ), incomplete ( "failed" , 1 ));
assert . equal ( ledgers . readNodes (). length , 1 );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 1 );
await make ( ledgers ). importNotes ( input );
});
it ( "source-upsert failure is typed and releases exclusion for retry" , async () => {
const ledgers = memoryLedgers ();
await assert . rejects ( make ({ ... ledgers , upsertSource : () => { throw new Error ( "synthetic metadata failure" ); } }). importNotes ( input ), incomplete ( "failed" , 0 ));
await make ( ledgers ). importNotes ( input );
});
for ( const filePath of [ "1.md" , "1.1.md" , "1 notes.md" , "1.1 notes.md" ]) it ( `imports and repeats identical text for ${ filePath } ` , async () => {
const ledgers = memoryLedgers ();
await make ( ledgers ). importNotes ({ ... input , filePath });
await make ( ledgers ). importNotes ({ ... input , filePath });
assert . equal ( ledgers . readSources (). length , 1 );
assert . equal ( ledgers . readSources ()[ 0 ] ! . extractions ? . length , 2 );
assert . equal ( ledgers . readNodes (). length , 2 );
});
});