extraction: the spine stage — cross-stretch assembly between extract and audit
Per-batch pipeline is now extract → spine → audit → merge (owner order, 2026-09-30), the spine inside the serialized audit section so it reads the graph exactly as the following audit will. Measured motive: 0 of 133 chains in the live corpus crossed a stretch boundary — no stage minted cross-stretch chains. - src/agent/subagents/spine.ts: SESSION/NOTES spine preambles + shared SPINE_CORE (span doctrine; extraction doctrines carried over), the submit_spine TypeBox schema, validateSpine (mechanical span law: every chain touches >=1 fresh endpoint AND >=1 existing digest endpoint; unknown ids, self-reference, and id collisions are loud), the digest prompt builder, and the runner (strong tier, retry-only). - extraction.ts: 'assembling' phase, spine stage in the per-need gate, usage/billing stage, assemble() inside withAuditLock for both front doors; first stretch of a source skips the call (empty digest, no spend); the audit sees COMBINED propositions (chunk + connectives), never chains; merger receives the combined findings. - core/graph.ts mergeFindings: chain endpoints resolve through the job-local map OR pass through verbatim as existing n_ ids (the spine's mixed namespace). - EXTRACTION_CORE: steps-of-reasoning selection bar (examples, notation glosses, and restatements that no argument needs are not steps). - Factory chunk budget 4096 -> 8192 (fewer seams, more local context). - healStringifiedArrays moved to the shared submit seam (submit-agent.ts). - Cost panel: Assembly row; usage records carry a spine stage (optional on legacy records). - Tests +5: prompt pins, span-law matrix, digest rendering, seeded end-to-end flow (spanning chain + connective, verbatim n_ endpoint, spine billed as its own stage), non-spanning refused + retried.
This commit is contained in:
@@ -30,7 +30,17 @@ import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"
|
||||
import { teacherProseBlocks } from "../../core/coverage.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { auditorGraphTools } from "../tools/graph.ts";
|
||||
import { runSubmitAgent } from "./submit-agent.ts";
|
||||
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||
import {
|
||||
SPINE_INSTRUCTIONS,
|
||||
NOTES_SPINE_INSTRUCTIONS,
|
||||
SPINE_EMPTY,
|
||||
buildSpinePrompt,
|
||||
combineFindings,
|
||||
runSpine,
|
||||
spineHasDigest,
|
||||
type SpineSubmit,
|
||||
} from "./spine.ts";
|
||||
|
||||
/**
|
||||
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign;
|
||||
@@ -199,32 +209,6 @@ const auditSchema = Type.Object({
|
||||
type NotesFindings = SessionFindings;
|
||||
type NotesAuditResult = AuditVerdicts;
|
||||
|
||||
// ── the weak-model string-heal (owner, 2026-09-28) ──
|
||||
//
|
||||
// OpenRouter log evidence: weak models under a big submit payload often
|
||||
// emit the whole `propositions` array as ONE JSON-STRING instead of a
|
||||
// native array ("propositions.0: must be object"). pi-agent-core runs
|
||||
// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim
|
||||
// point (same doctrine as restoreMathEscapes at the display seam: heal
|
||||
// weak-model wire behavior, don't punish it with a retry). Best-effort:
|
||||
// heal only a string that parses to an array; anything else passes
|
||||
// through untouched and fails validation exactly as before.
|
||||
function healStringifiedArrays(raw: unknown): unknown {
|
||||
if (typeof raw !== "object" || raw === null) return raw;
|
||||
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
|
||||
for (const key of ["propositions", "chains"]) {
|
||||
const v = out[key];
|
||||
if (typeof v !== "string" || v.trim().length === 0) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(v);
|
||||
if (Array.isArray(parsed)) out[key] = parsed;
|
||||
} catch {
|
||||
// not a clean stringify (e.g. truncated) — leave for validation
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── the submit seam (owner, 2026-09-28; redesigned 2026-09-30) ──
|
||||
//
|
||||
// Every top-level field on each submit tool is REQUIRED — a payload
|
||||
@@ -314,9 +298,11 @@ teacher, or this reading: not "the learner understands X" but X. A misconception
|
||||
correction. Each statement is one self-contained reasoning step at full precision.
|
||||
|
||||
Extract the load-bearing ideas of the subject — what a reference on it would teach — not the full run of
|
||||
the discourse. Every proposition must stand alone for a reader who never saw this source: names and
|
||||
statements may not lean on the source's own scaffolding (claim numbering, axiom tags, arc labels); a
|
||||
claim that only matters as part of how this document is organized is not knowledge — drop it.
|
||||
the discourse. The graph stores propositions as STEPS OF REASONING: a proposition earns its place only
|
||||
when the source's arguments run through it — examples, notation glosses, and restatements that no
|
||||
argument needs are not steps. Every proposition must stand alone for a reader who never saw this source:
|
||||
names and statements may not lean on the source's own scaffolding (claim numbering, axiom tags, arc
|
||||
labels); a claim that only matters as part of how this document is organized is not knowledge — drop it.
|
||||
|
||||
SUBMIT with one submit_findings call: every proposition with a short name and a precise statement, and
|
||||
every chain as premises + conclusion. Premises within one chain are used TOGETHER — independent arguments
|
||||
@@ -460,7 +446,7 @@ function runAuditor(
|
||||
* audit+merge critical section is queued behind another job's; the
|
||||
* extractor is done but the auditor may not start until the graph is
|
||||
* stable. */
|
||||
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "auditing" | "merging";
|
||||
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "assembling" | "auditing" | "merging";
|
||||
|
||||
/** One in-flight pipeline run. Module-level: one process, one registry.
|
||||
* Exported for the status payload's consumer typing (the dock). */
|
||||
@@ -487,6 +473,7 @@ export interface RunningExtraction {
|
||||
* context-window blobs read this through /api/graph/status). */
|
||||
usage: {
|
||||
extractor?: ExtractionStageUsage;
|
||||
spine?: ExtractionStageUsage;
|
||||
auditor?: ExtractionStageUsage;
|
||||
};
|
||||
/** True-cancellation handle (the dock's abort button): one controller per
|
||||
@@ -556,6 +543,7 @@ export interface ExtractionModels {
|
||||
/** Test seam: a scripted engine per stage — checked FIRST, ahead of the
|
||||
* engineFor chain (production wires no direct values). */
|
||||
extractor?: PiEngine;
|
||||
spine?: PiEngine;
|
||||
auditor?: PiEngine;
|
||||
/** Settings-resolved engine per role — the same slot treatment the
|
||||
* job runner got, read FRESH per stage call (a settings PUT between
|
||||
@@ -634,6 +622,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
function refuseIfStagesUnready(): void {
|
||||
const stages: readonly [ModelRole, PiEngine | undefined][] = [
|
||||
["extractor", stageEngine("extractor", deps.extractor)],
|
||||
["spine", stageEngine("spine", deps.spine)],
|
||||
["auditor", stageEngine("auditor", deps.auditor)],
|
||||
];
|
||||
const missing = new Set<ModelSlotName>();
|
||||
@@ -682,7 +671,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
}
|
||||
|
||||
/** Accumulate one billed step into the run's registry entry (live). */
|
||||
function recordStep(jobKey: string, stage: "extractor" | "auditor", usage: CallUsage, model: string): void {
|
||||
function recordStep(jobKey: string, stage: "extractor" | "spine" | "auditor", usage: CallUsage, model: string): void {
|
||||
const run = runningExtractions.get(jobKey);
|
||||
if (!run) return; // not running (tests driving stages directly)
|
||||
run.usage[stage] ??= { model, steps: [] };
|
||||
@@ -748,6 +737,50 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** THE SPINE (owner, 2026-09-30): the assembly stage between extract
|
||||
* and audit, INSIDE the serialized section (it reads the graph-so-far
|
||||
* digest; its output rides the same audit + merge). Skipped without a
|
||||
* call when the source owns nothing yet (first stretch — nothing to
|
||||
* join). Identity stays the auditor's: the spine submits chains +
|
||||
* connective propositions only, and the audit sees the COMBINED
|
||||
* propositions — chunk findings plus connectives — never chains. */
|
||||
async function assemble(
|
||||
jobKey: string,
|
||||
sourceId: string,
|
||||
kind: "chat" | "notes",
|
||||
findings: SessionFindings,
|
||||
): Promise<SessionFindings> {
|
||||
const signal = runningExtractions.get(jobKey)?.controller.signal;
|
||||
signal?.throwIfAborted();
|
||||
const graph = getGraph(graphStore);
|
||||
if (!spineHasDigest(graph, sourceId)) {
|
||||
console.info(`[extraction ${jobKey}] spine skipped — empty digest (first stretch)`);
|
||||
return findings;
|
||||
}
|
||||
console.info(
|
||||
`[extraction ${jobKey}] spine starting (${graph.nodes.length} graph nodes, ` +
|
||||
`${findings.propositions.length} fresh propositions)`,
|
||||
);
|
||||
const t0 = performance.now();
|
||||
const spine: SpineSubmit = await runSpine(
|
||||
stageEngine("spine", deps.spine),
|
||||
kind === "chat" ? SPINE_INSTRUCTIONS : NOTES_SPINE_INSTRUCTIONS,
|
||||
buildSpinePrompt(graph, sourceId, findings),
|
||||
new Set(findings.propositions.map((p) => p.id)),
|
||||
new Set(graph.nodes.map((n) => n.id)),
|
||||
signal,
|
||||
(usage, model, step, finishReason, tools) => {
|
||||
recordStep(jobKey, "spine", callUsage(usage), model);
|
||||
console.info(`[extraction] spine step ${step}: ${finishReason}, tools: ${tools}`);
|
||||
},
|
||||
);
|
||||
console.info(
|
||||
`[extraction ${jobKey}] spine done in ${Math.round(performance.now() - t0)}ms — ` +
|
||||
`${spine.chains.length} chain(s), ${spine.propositions.length} connective(s)`,
|
||||
);
|
||||
return spine === SPINE_EMPTY ? findings : combineFindings(findings, spine);
|
||||
}
|
||||
|
||||
async function audit(jobKey: string, findings: SessionFindings): Promise<AuditVerdicts> {
|
||||
const signal = runningExtractions.get(jobKey)?.controller.signal;
|
||||
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
|
||||
@@ -816,6 +849,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
stats,
|
||||
stages: {
|
||||
extractor: run.usage.extractor ?? { model: "unknown", steps: [] },
|
||||
spine: run.usage.spine ?? { model: "unknown", steps: [] },
|
||||
auditor: run.usage.auditor ?? { model: "unknown", steps: [] },
|
||||
},
|
||||
};
|
||||
@@ -935,10 +969,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// SERIALIZED AUDIT (owner 2026-09-28): audit + merge are the
|
||||
// graph-mutating critical section — one job at a time,
|
||||
// process-wide, so the auditor explores the graph as it
|
||||
// stands AFTER every earlier job's merge.
|
||||
// stands AFTER every earlier job's merge. The spine rides the
|
||||
// same section (owner order, 2026-09-30): extract → spine →
|
||||
// audit → merge.
|
||||
return withAuditLock(jobKey, "chat", undefined, async () => {
|
||||
setPhase(jobKey, "assembling", "chat", undefined, part);
|
||||
const combined = await assemble(jobKey, chatId, "chat", findings);
|
||||
setPhase(jobKey, "auditing", "chat", undefined, part);
|
||||
const auditResult = await audit(jobKey, findings);
|
||||
const auditResult = await audit(jobKey, combined);
|
||||
setPhase(jobKey, "merging", "chat", undefined, part);
|
||||
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
|
||||
// merge. The deletion route aborts this run first and runs its purge
|
||||
@@ -955,7 +993,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// merges in), the stored fingerprint won't match its new
|
||||
// one and it re-opens: honest by construction.
|
||||
const foldedAt = new Date().toISOString();
|
||||
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
|
||||
const batchStats = mergeFindings(chatId, combined, auditResult, { chatStore, graphStore }, {
|
||||
foldedAt,
|
||||
coverage: batch
|
||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||
@@ -1088,14 +1126,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// parallel, but audit + merge one at a time, process-wide —
|
||||
// the auditor explores the graph as it stands AFTER every
|
||||
// earlier job's merge. Reacquired per chunk so runs
|
||||
// interleave fairly.
|
||||
// interleave fairly. The spine rides the same section
|
||||
// (owner order, 2026-09-30): extract → spine → audit → merge.
|
||||
return withAuditLock(jobKey, "notes", input.filePath, async () => {
|
||||
setPhase(jobKey, "assembling", "notes", input.filePath, part);
|
||||
const combined = await assemble(jobKey, id, "notes", findings);
|
||||
setPhase(jobKey, "auditing", "notes", input.filePath, part);
|
||||
const auditResult = await audit(jobKey, findings);
|
||||
const auditResult = await audit(jobKey, combined);
|
||||
setPhase(jobKey, "merging", "notes", input.filePath, part);
|
||||
const merged = mergeFindings(
|
||||
id,
|
||||
findings,
|
||||
combined,
|
||||
auditResult,
|
||||
{ chatStore, graphStore },
|
||||
{ foldedAt: input.importedAt },
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* The spine stage (owner-approved design, 2026-09-30): the extraction
|
||||
* pipeline's ASSEMBLY pass. Extraction walks a source in stretches, each
|
||||
* stretch-blind — but the source's reasoning runs over the whole span,
|
||||
* and the data showed it: 0 of 133 chains crossed a stretch boundary.
|
||||
* The spine runs once per batch, INSIDE the serialized audit section,
|
||||
* between the extract and the audit (owner-specified order):
|
||||
*
|
||||
* extract → [lock: spine → audit → merge] → next batch
|
||||
*
|
||||
* It sees the same-source digest (everything merged so far) plus the
|
||||
* fresh stretch's findings, and mints ONLY what spans them: cross-stretch
|
||||
* chains and the connective propositions a chain needs that no stretch
|
||||
* stated on its own. The SPAN LAW is mechanical (validateSpine below):
|
||||
* every chain must touch ≥1 fresh endpoint AND ≥1 existing endpoint —
|
||||
* induction over the walk means no pair ever needs a second chance.
|
||||
*
|
||||
* Identity stays the AUDITOR's lane (owner ruling): the spine submits no
|
||||
* links. Its connective propositions ride into the audit as ordinary
|
||||
* findings — the auditor links them like any other — and its chains
|
||||
* resolve through the merger's job-local map (endpoints may be existing
|
||||
* graph ids verbatim; see mergeFindings' mixed-namespace resolution).
|
||||
*
|
||||
* Session and notes front doors share this stage; only the preamble
|
||||
* differs (the extractor's law, verbatim).
|
||||
*/
|
||||
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { Type, type Static } from "typebox";
|
||||
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import type { ExtractedChain, SessionFindings } from "../../core/contracts.ts";
|
||||
import type { DerivedGraph } from "../../core/graph.ts";
|
||||
|
||||
// ─── the prompts ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const SESSION_SPINE_PREAMBLE =
|
||||
"You are the spine stage of a learning application's extraction pipeline. One source — a teaching " +
|
||||
"session, walked in stretches of the teacher's prose in reading order — has just advanced by one " +
|
||||
"stretch; you join what the new stretch established into the source's accumulated line of argument.";
|
||||
|
||||
export const NOTES_SPINE_PREAMBLE =
|
||||
"You are the spine stage of a learning application's extraction pipeline. One source — the author's " +
|
||||
"personal notes, walked in stretches in reading order — has just advanced by one stretch; you join " +
|
||||
"what the new stretch established into the source's accumulated line of argument.";
|
||||
|
||||
export const SPINE_CORE = `THE GOAL: carry the source's line of argument ACROSS stretches. Extraction walks the source in
|
||||
stretches; each stretch reports its own propositions and chains — but the source's reasoning runs over
|
||||
the whole span, and no stretch sees more than its own part. You see everything extracted from this
|
||||
source so far, plus what the latest stretch just established. Your deliverable: the chains that JOIN them.
|
||||
|
||||
Every chain you submit must SPAN: its premises and conclusion may not all come from the same stretch. A
|
||||
chain inside one stretch is that stretch's own work; a chain the graph already holds exists. Direction is
|
||||
whatever the reasoning runs — earlier into later, or later material completing an argument opened earlier.
|
||||
|
||||
The extraction doctrines bind you unchanged: premises JOINTLY entail the conclusion; entailment is the
|
||||
only link that earns a chain; every proposition stands alone for a reader who never saw the source;
|
||||
load-bearing connections only — when in doubt, omit.
|
||||
|
||||
Restatements: when the fresh stretch restates a proposition the graph already holds, build your chains on
|
||||
the EXISTING id, not the restatement.
|
||||
|
||||
SUBMIT with one submit_spine call: the connecting chains, and only the connective propositions a chain
|
||||
needs that no stretch stated on its own — a missing middle step, the general claim a stretch argued only
|
||||
locally. Pass empty arrays when the stretch stands alone — never omit the fields. The tool's field
|
||||
descriptions carry the details.`;
|
||||
|
||||
export const SPINE_INSTRUCTIONS = `${SESSION_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
|
||||
export const NOTES_SPINE_INSTRUCTIONS = `${NOTES_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
|
||||
|
||||
// ─── the submit schema ───────────────────────────────────────────────────────
|
||||
|
||||
const spinePropositionProps = {
|
||||
id: Type.String({ description: 'Local id: "s1", "s2", … — referenced by chains.' }),
|
||||
name: Type.String({
|
||||
description:
|
||||
"A very short label — established terminology, the term the discipline itself uses. " +
|
||||
"Not a sentence, needn't be unique or self-contained: this becomes the node's global label.",
|
||||
}),
|
||||
statement: Type.String({
|
||||
description:
|
||||
"One true, self-contained, world-facing reasoning step at full precision — the connective " +
|
||||
"tissue a spanning chain needs that no stretch stated on its own.",
|
||||
}),
|
||||
aliases: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Verbatim phrasings the source used for the same proposition, if any.",
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const endpoint = (role: string) =>
|
||||
Type.String({
|
||||
description:
|
||||
`The proposition id this ${role} — EITHER an existing proposition id from the digest, ` +
|
||||
'verbatim ("n_…"), OR the local id of a proposition in this submission ("s1", …) or of the ' +
|
||||
'fresh stretch\'s findings ("p1", …).',
|
||||
});
|
||||
|
||||
const spineChainSchema = Type.Object({
|
||||
premises: Type.Array(endpoint("premise uses"), {
|
||||
minItems: 1,
|
||||
description:
|
||||
"Proposition ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
|
||||
}),
|
||||
conclusion: endpoint("conclusion is"),
|
||||
});
|
||||
|
||||
const spineSchema = Type.Object({
|
||||
propositions: Type.Array(Type.Object(spinePropositionProps), {
|
||||
description:
|
||||
"Connective propositions ONLY — steps a spanning chain needs that no stretch stated on " +
|
||||
"its own. Usually empty.",
|
||||
}),
|
||||
chains: Type.Array(spineChainSchema, {
|
||||
description:
|
||||
"The cross-stretch chains — each must join material that arrived at different times " +
|
||||
"(≥1 endpoint from the fresh stretch, ≥1 from before it). Pass [] when the stretch " +
|
||||
"stands alone — never omit the field.",
|
||||
}),
|
||||
});
|
||||
|
||||
/** What the spine SUBMITS. fillSpine normalizes it; validateSpine checks
|
||||
* the span laws; the pipeline combines it with the stretch's findings
|
||||
* before the audit (the auditor sees chunk + connective propositions,
|
||||
* propositions only — never chains, owner 2026-09-30). */
|
||||
export type SpineSubmit = Static<typeof spineSchema>;
|
||||
|
||||
export const SPINE_EMPTY: SpineSubmit = { propositions: [], chains: [] };
|
||||
|
||||
export function fillSpine(raw: SpineSubmit): SpineSubmit {
|
||||
return {
|
||||
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
|
||||
chains: raw.chains,
|
||||
};
|
||||
}
|
||||
|
||||
/** The span laws — the LOUD channel (error-back retry), mirroring
|
||||
* validateFindingsChains. `chunkIds` = the fresh stretch's finding ids
|
||||
* ("p1"…); `existingIds` = every node id already in the graph. A chain
|
||||
* must touch at least one FRESH endpoint (chunk finding or this
|
||||
* submission's connectives) and at least one EXISTING endpoint — the
|
||||
* mechanical form of the owner's all-or-nothing span rule. Identical
|
||||
* re-assertions are not errors: the merger dedupes them silently. */
|
||||
export function validateSpine(value: SpineSubmit, chunkIds: ReadonlySet<string>, existingIds: ReadonlySet<string>): string | null {
|
||||
const localIds = new Set(value.propositions.map((p) => p.id));
|
||||
for (const p of value.propositions) {
|
||||
if (chunkIds.has(p.id) || existingIds.has(p.id)) {
|
||||
return `propositions: "${p.id}" collides with an id this submission does not own — connective ids are fresh ("s1", "s2", …).`;
|
||||
}
|
||||
}
|
||||
const resolves = (id: string) => chunkIds.has(id) || localIds.has(id) || existingIds.has(id);
|
||||
const isFresh = (id: string) => chunkIds.has(id) || localIds.has(id);
|
||||
for (const chain of value.chains) {
|
||||
if (chain.premises.length === 0) {
|
||||
return "chains: premises must name at least one proposition.";
|
||||
}
|
||||
const endpoints = [...chain.premises, chain.conclusion];
|
||||
const unknown = endpoints.find((id) => !resolves(id));
|
||||
if (unknown !== undefined) {
|
||||
return `chains: "${unknown}" is not an existing digest id, a fresh-stretch finding id, or one of this call's proposition ids.`;
|
||||
}
|
||||
if (chain.premises.includes(chain.conclusion)) {
|
||||
return "chains: a conclusion cannot be its own premise.";
|
||||
}
|
||||
if (!endpoints.some(isFresh)) {
|
||||
return `chains: [${endpoints.join(", ")}] is entirely existing material — a chain the graph already had its chance to hold. Every chain must join the fresh stretch to what came before.`;
|
||||
}
|
||||
if (!endpoints.some((id) => existingIds.has(id))) {
|
||||
return `chains: [${endpoints.join(", ")}] lives entirely inside the fresh stretch — that is the stretch extractor's work, not yours. Every chain must join the fresh stretch to what came before.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── the digest ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Render the same-source digest + fresh findings for the spine's prompt.
|
||||
* Pure: a string fold over the derived graph. The digest carries ids
|
||||
* verbatim ("n_…") — that is what the spine references in its chains. */
|
||||
export function buildSpinePrompt(graph: DerivedGraph, sourceId: string, findings: SessionFindings): string {
|
||||
const nameOf = new Map(graph.nodes.map((n) => [n.id, n.name]));
|
||||
const ours = graph.nodes.filter((n) => n.provenance.some((pt) => pt.chatId === sourceId));
|
||||
const ourChains = graph.chains.filter((c) => c.provenance.some((pt) => pt.chatId === sourceId));
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
ours.length > 0
|
||||
? `THE GRAPH SO FAR — this source's accumulated propositions (ids verbatim, referenced by your chains):`
|
||||
: `THE GRAPH SO FAR — this source has no accumulated propositions yet (nothing to join).`,
|
||||
);
|
||||
for (const n of ours) lines.push(` ${n.id} | ${n.name} | ${n.statement.replace(/\s+/g, " ")}`);
|
||||
if (ourChains.length > 0) {
|
||||
lines.push(`ARGUMENTS ALREADY RECORDED (do not re-submit these):`);
|
||||
for (const c of ourChains) {
|
||||
const names = c.premises.map((p) => nameOf.get(p) ?? p).join(" ∧ ");
|
||||
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${nameOf.get(c.conclusion) ?? c.conclusion}`);
|
||||
}
|
||||
}
|
||||
lines.push(``);
|
||||
lines.push(`THE FRESH STRETCH — what the extractor just reported (local ids "p1", …):`);
|
||||
for (const p of findings.propositions) lines.push(` ${p.id} | ${p.name} | ${p.statement.replace(/\s+/g, " ")}`);
|
||||
if (findings.chains.length > 0) {
|
||||
lines.push(`ITS OWN CHAINS (this stretch's local work):`);
|
||||
const freshName = new Map(findings.propositions.map((p) => [p.id, p.name]));
|
||||
for (const c of findings.chains) {
|
||||
const names = c.premises.map((p) => freshName.get(p) ?? p).join(" ∧ ");
|
||||
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${freshName.get(c.conclusion) ?? c.conclusion}`);
|
||||
}
|
||||
}
|
||||
lines.push(``);
|
||||
lines.push(`Join the source's line of argument across these two moments.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Whether the spine has anything to see at all — the first stretch of a
|
||||
* source has an empty digest, and the call is skipped (no spine run, no
|
||||
* spend) until a source owns at least one merged proposition. */
|
||||
export function spineHasDigest(graph: DerivedGraph, sourceId: string): boolean {
|
||||
return graph.nodes.some((n) => n.provenance.some((pt) => pt.chatId === sourceId));
|
||||
}
|
||||
|
||||
// ─── the runner ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** The spine over one batch's findings — a single submit call, retry-only
|
||||
* (no exploration tools; everything it needs is in the prompt). */
|
||||
export function runSpine(
|
||||
engine: PiEngine | undefined,
|
||||
systemPrompt: string,
|
||||
prompt: string,
|
||||
chunkIds: ReadonlySet<string>,
|
||||
existingIds: ReadonlySet<string>,
|
||||
signal: AbortSignal | undefined,
|
||||
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
|
||||
): Promise<SpineSubmit> {
|
||||
return runSubmitAgent<SpineSubmit>({
|
||||
role: "spine",
|
||||
toolName: "submit_spine",
|
||||
toolDescription: "Submit the cross-stretch chains (and any connective propositions they need).",
|
||||
parameters: spineSchema,
|
||||
fill: fillSpine,
|
||||
validate: (value) => validateSpine(value, chunkIds, existingIds),
|
||||
systemPrompt,
|
||||
prompt,
|
||||
engine,
|
||||
signal,
|
||||
onStep,
|
||||
neverSubmitted: "the spine never submitted its chains",
|
||||
prepareArguments: healStringifiedArrays,
|
||||
});
|
||||
}
|
||||
|
||||
/** The combined findings the AUDIT + MERGE see: the stretch's propositions
|
||||
* plus the spine's connectives (ONE findings set — the auditor links
|
||||
* connectives like anything else), and both chain sets (endpoints may be
|
||||
* existing graph ids — the merger resolves the mixed namespace). */
|
||||
export function combineFindings(findings: SessionFindings, spine: SpineSubmit): SessionFindings {
|
||||
return {
|
||||
propositions: [...findings.propositions, ...spine.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] }))],
|
||||
chains: [...findings.chains, ...(spine.chains as ExtractedChain[])],
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,6 @@
|
||||
* schema-invalid call). The submitted value is captured from the execute
|
||||
* seam, which only fires on a schema-valid call — the ??-fill (the old zod
|
||||
* defaults) applies at the capture.
|
||||
*
|
||||
* Extraction passes its own weak-model argument heal (healStringifiedArrays)
|
||||
* through `prepareArguments`; the default is identity pass-through.
|
||||
*/
|
||||
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
@@ -18,6 +15,34 @@ import type { ModelRole } from "../../../config.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
|
||||
// ── the weak-model string-heal (owner, 2026-09-28; moved here from
|
||||
// extraction.ts with the spine stage, 2026-09-30 — the heal is wire-format
|
||||
// medicine shared by every submit agent) ──
|
||||
//
|
||||
// OpenRouter log evidence: weak models under a big submit payload often
|
||||
// emit a whole array as ONE JSON-STRING instead of a native array
|
||||
// ("propositions.0: must be object"). pi-agent-core runs
|
||||
// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim
|
||||
// point (same doctrine as restoreMathEscapes at the display seam: heal
|
||||
// weak-model wire behavior, don't punish it with a retry). Best-effort:
|
||||
// heal only a string that parses to an array; anything else passes
|
||||
// through untouched and fails validation exactly as before.
|
||||
export function healStringifiedArrays(raw: unknown): unknown {
|
||||
if (typeof raw !== "object" || raw === null) return raw;
|
||||
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
|
||||
for (const key of ["propositions", "chains"]) {
|
||||
const v = out[key];
|
||||
if (typeof v !== "string" || v.trim().length === 0) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(v);
|
||||
if (Array.isArray(parsed)) out[key] = parsed;
|
||||
} catch {
|
||||
// not a clean stringify (e.g. truncated) — leave for validation
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runSubmitAgent<T>(opts: {
|
||||
role: ModelRole;
|
||||
toolName: string;
|
||||
|
||||
@@ -92,6 +92,11 @@ export interface ExtractionRunUsage {
|
||||
stages: {
|
||||
extractor: ExtractionStageUsage;
|
||||
auditor: ExtractionStageUsage;
|
||||
/** The spine stage (2026-09-30): cross-stretch chain assembly.
|
||||
* OPTIONAL — records written before the stage existed (and runs
|
||||
* whose digest was empty, so the call was skipped) carry no
|
||||
* entry; readers default it to an empty step-set. */
|
||||
spine?: ExtractionStageUsage;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface GraphCostSummary {
|
||||
* historical spend on either is simply not displayed.) */
|
||||
stages: {
|
||||
extraction: StageCost;
|
||||
spine: StageCost;
|
||||
audit: StageCost;
|
||||
};
|
||||
/** Newest first, capped at `recentCap` (default 10). */
|
||||
@@ -112,11 +113,16 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||
* same output — no clock, no fs, no store reads. */
|
||||
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const extraction = emptyStage();
|
||||
const spine = emptyStage();
|
||||
const audit = emptyStage();
|
||||
const runs: RecentRun[] = [];
|
||||
|
||||
const foldExtractionRun = (run: ExtractionRunUsage, title: string) => {
|
||||
const stages = run.stages ?? { extractor: { model: "unknown", steps: [] }, auditor: { model: "unknown", steps: [] } };
|
||||
// The spine stage is OPTIONAL on the record (2026-09-30): records
|
||||
// written before it existed (and runs whose digest was empty, so
|
||||
// the call was skipped) carry no entry — default to empty steps.
|
||||
const spineStage = stages.spine ?? { model: "unknown", steps: [] };
|
||||
// Per-stage: tokens always count; dollars only when priced.
|
||||
extraction.runs++;
|
||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||
@@ -124,6 +130,14 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
if (extDollars === null) extraction.unpricedRuns++;
|
||||
else extraction.dollars += extDollars;
|
||||
|
||||
if (spineStage.steps.length > 0) {
|
||||
spine.runs++;
|
||||
mergeTokens(spine.tokens, spineStage.steps);
|
||||
const spDollars = stageDollars(spineStage.model, spineStage.steps);
|
||||
if (spDollars === null) spine.unpricedRuns++;
|
||||
else spine.dollars += spDollars;
|
||||
}
|
||||
|
||||
audit.runs++;
|
||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||
@@ -135,7 +149,7 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const runDollars = allStages.every((d) => d === null)
|
||||
? null
|
||||
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
||||
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
||||
const allSteps = [...stages.extractor.steps, ...spineStage.steps, ...stages.auditor.steps];
|
||||
runs.push({
|
||||
startedAt: run.startedAt,
|
||||
at: run.at,
|
||||
@@ -163,7 +177,7 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
return {
|
||||
totalDollars: extraction.dollars + audit.dollars,
|
||||
unpricedRuns,
|
||||
stages: { extraction, audit },
|
||||
stages: { extraction, spine, audit },
|
||||
runs: capped,
|
||||
};
|
||||
}
|
||||
|
||||
+23
-10
@@ -7,15 +7,21 @@
|
||||
*
|
||||
* extractor (LLM) → SessionFindings: the raw reasoning chains —
|
||||
* propositions (lonely relevant ones included) and
|
||||
* follows/supports edges,
|
||||
* from the source ALONE (whole session as markdown,
|
||||
* or raw notes). Fully trusted: no stage re-checks it.
|
||||
* chains, from the stretch alone (one batch of the
|
||||
* source). Fully trusted: no stage re-checks it.
|
||||
* spine (LLM) → the ASSEMBLY stage (2026-09-30): cross-stretch
|
||||
* chains + connective propositions joining the
|
||||
* fresh stretch to the same-source digest (the span
|
||||
* law is mechanical — src/agent/subagents/spine.ts).
|
||||
* No links — identity stays the auditor's.
|
||||
* auditor (LLM) → AuditVerdicts: identity links (local id → an existing
|
||||
* node, or create) via graph-exploration tools —
|
||||
* links ONLY (owner, 2026-09-28 evening: the auditor
|
||||
* never mints edges; cross-source connection happens
|
||||
* solely through identified equivalences). Never sees
|
||||
* the source; never drops.
|
||||
* the source; never drops; never sees chains — its
|
||||
* input is the chunk propositions + the spine's
|
||||
* connectives, propositions only.
|
||||
* merger (HERE) → mergeFindings(): deterministic ledger appends
|
||||
*
|
||||
* The merger is deliberately the ONLY stage that touches the ledgers, and it
|
||||
@@ -194,10 +200,11 @@ export function mergeFindings(
|
||||
mapping.set(p.id, graphNodeId);
|
||||
}
|
||||
|
||||
// 2. Chains — the extractor's own, the ONLY chain source; the stored
|
||||
// truth (chains-primary, owner 2026-09-30). Endpoints are this
|
||||
// job's local proposition ids and resolve through the job-local
|
||||
// map; chains carry the run's chat-level provenance point and
|
||||
// 2. Chains — the extractor's and the spine's (the two chain sources;
|
||||
// the stored truth, chains-primary, owner 2026-09-30). Endpoints
|
||||
// resolve through the job-local map OR pass through verbatim when
|
||||
// they name an existing node (the spine references digest ids
|
||||
// directly); chains carry the run's chat-level provenance point and
|
||||
// dedupe by (premises, conclusion) already asserted by THIS chat —
|
||||
// an assertion by another chat stays untouched (provenance unions
|
||||
// at read time; a known argument re-stated appends under the SAME
|
||||
@@ -211,7 +218,13 @@ export function mergeFindings(
|
||||
if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
|
||||
}
|
||||
const emitChain = (chain: ExtractedChain): void => {
|
||||
const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
|
||||
// Mixed namespace (spine stage, 2026-09-30): an endpoint resolves
|
||||
// through the job-local map (this submit's propositions — chunk
|
||||
// findings and spine connectives alike) OR passes through verbatim
|
||||
// when it names an existing node (a spine chain referencing digest
|
||||
// ids directly). Anything else is unresolvable — skip.
|
||||
const resolve = (endpoint: string): string | undefined =>
|
||||
mapping.get(endpoint) ?? (existingIds.has(endpoint) ? endpoint : undefined);
|
||||
const conclusion = resolve(chain.conclusion);
|
||||
if (conclusion === undefined) return; // unresolvable endpoint — skip
|
||||
// Duplicate local ids resolving to one node (two phrasings of one
|
||||
@@ -228,7 +241,7 @@ export function mergeFindings(
|
||||
assertedByChat.add(key);
|
||||
stats.chains!++;
|
||||
};
|
||||
for (const chain of findings.chains) emitChain(chain); // the extractor's chains — the ONLY chain source // the extractor's edges — the ONLY edge source
|
||||
for (const chain of findings.chains) emitChain(chain); // the extractor's chains + the spine's spanning chains — the two chain sources
|
||||
|
||||
// 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
|
||||
// only now, after every append succeeded, is the batch covered. The
|
||||
|
||||
@@ -45,6 +45,7 @@ export const BACKGROUND_MODEL_ROLES = [
|
||||
"demoBuilder",
|
||||
"visualMaker",
|
||||
"extractor",
|
||||
"spine",
|
||||
"auditor",
|
||||
"partitioner",
|
||||
"problemGenerator",
|
||||
@@ -226,6 +227,10 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
|
||||
researcher: "cheap",
|
||||
demoBuilder: "cheap",
|
||||
extractor: "cheap",
|
||||
// The spine (2026-09-30): cross-stretch assembly — judgment-dense
|
||||
// work over the whole same-source digest, strong tier like the
|
||||
// practice trio.
|
||||
spine: "strong",
|
||||
auditor: "cheap",
|
||||
visualMaker: "vision",
|
||||
pdfReader: "vision",
|
||||
@@ -384,7 +389,7 @@ export const FACTORY_SETTINGS: FactorySettings = {
|
||||
sessionNameSerif: true,
|
||||
mdRendering: { headingWeight: 900, boldColor: null, italicColor: null },
|
||||
},
|
||||
graph: { enabled: true, extractionChunkTokens: 4096 },
|
||||
graph: { enabled: true, extractionChunkTokens: 8192 },
|
||||
};
|
||||
|
||||
/** The chunk budget's validated floor (GraphSettings.extractionChunkTokens
|
||||
|
||||
Reference in New Issue
Block a user