From 1f612d73a2490fbd8c0c7e58945238053ffdbfc7 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 08:09:39 +0000 Subject: [PATCH] feat: add se-trace context-graph (RFC-0004) Co-authored-by: emmanuel-a-otchere --- src/cli/se-trace.ts | 22 +++++++ src/context-graph.ts | 135 +++++++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 75 ++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 src/context-graph.ts diff --git a/src/cli/se-trace.ts b/src/cli/se-trace.ts index cf4ef02..a02db5f 100644 --- a/src/cli/se-trace.ts +++ b/src/cli/se-trace.ts @@ -5,6 +5,7 @@ * - inspect Print a human-readable summary of a COTS. * - cite Print the evidence chain for a specific decision. * - visualize Render a COTS as a Reasoning Roadmap (Mermaid + HTML). + * - context-graph Render a COTS's Context + Evidence as a Context Graph (RFC-0004). */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; @@ -13,6 +14,7 @@ import { Command } from "commander"; import { inspect } from "../inspect.js"; import { cite } from "../cite.js"; import { toMermaid, toHtml } from "../visualize.js"; +import { toContextGraphMermaid, toContextGraphHtml } from "../context-graph.js"; import type { Cots } from "../cots.js"; // Lightweight conformance check (subset of the canonical test suite). For @@ -176,4 +178,24 @@ program console.log(`HTML: ${htmlPath}`); }); +program + .command("context-graph") + .description("Render a COTS's Context + Evidence parts as a Context Graph (Mermaid + HTML, RFC-0004)") + .argument("", "Path to a COTS JSON file") + .option("-o, --out ", "Output directory", ".") + .action((path: string, opts: { out: string }) => { + const cots = loadCots(path); + const baseName = basename(path, extname(path)); + const mermaid = toContextGraphMermaid(cots); + const html = toContextGraphHtml(cots); + const mermaidPath = resolve(opts.out, `${baseName}.context-graph.mermaid`); + const htmlPath = resolve(opts.out, `${baseName}.context-graph.html`); + mkdirSync(dirname(mermaidPath), { recursive: true }); + mkdirSync(dirname(htmlPath), { recursive: true }); + writeFileSync(mermaidPath, mermaid); + writeFileSync(htmlPath, html); + console.log(`Mermaid: ${mermaidPath}`); + console.log(`HTML: ${htmlPath}`); + }); + program.parse(); diff --git a/src/context-graph.ts b/src/context-graph.ts new file mode 100644 index 0000000..2b07c84 --- /dev/null +++ b/src/context-graph.ts @@ -0,0 +1,135 @@ +/** + * Context Graph: render a COTS's Context + Evidence parts as a graph. + * + * Nodes: context parameters, constraints, source artifacts, evidence, + * claims, decisions. Edges carry a typed relationship from the RFC-0004 + * vocabulary floor: supports, contradicts, contextualises, constrains, set-by. + * + * Emitted as Mermaid plus a self-contained HTML page, same pattern as + * visualize.ts (Reasoning Roadmap). + */ + +import type { Cots } from "./cots.js"; + +export const toContextGraphMermaid = (cots: Cots): string => { + const lines: string[] = ["graph LR"]; + lines.push(" classDef param fill:#ede9fe,stroke:#5b21b6,color:#5b21b6"); + lines.push(" classDef constraint fill:#ffedd5,stroke:#9a3412,color:#9a3412"); + lines.push(" classDef source fill:#fef3c7,stroke:#92400e,color:#92400e"); + lines.push(" classDef claim fill:#dbeafe,stroke:#1e40af,color:#1e40af"); + lines.push(" classDef decision fill:#dcfce7,stroke:#166534,color:#166534"); + lines.push(" classDef modelGenerated fill:#fee2e2,stroke:#991b1b,color:#991b1b"); + lines.push(""); + + const contextId = "context"; + lines.push(` ${contextId}["context\\n${cots.context.model.model_name} v${cots.context.model.model_version}"]:::param`); + + const params = cots.context.parameters as unknown as Record; + for (const [key, value] of Object.entries(params)) { + const nodeId = `param_${key}`; + const label = `${key} = ${value === null ? "null" : String(value)}`; + lines.push(` ${nodeId}["${label}"]:::param`); + lines.push(` ${contextId} -. set-by .-> ${nodeId}`); + } + + for (const c of cots.context.constraints) { + lines.push(` ${safeId(c.id)}["CONSTRAINT ${c.type}: ${truncate(c.value, 40)}"]:::constraint`); + lines.push(` ${safeId(c.id)} -. constrains .-> ${contextId}`); + } + + for (const a of cots.inputs.source_artifacts) { + const label = `${a.origin.system_name} v${a.origin.system_version}\\n${a.version.content_hash.slice(0, 8)}...`; + lines.push(` ${safeId(a.id)}["${label}"]:::source`); + } + + for (const d of cots.decisions) { + const dLabel = `${d.validation_status.status.toUpperCase()}\\n${truncate(d.decision_text, 60)}`; + lines.push(` ${safeId(d.id)}["${dLabel}"]:::decision`); + for (const [key] of Object.entries(params)) { + lines.push(` param_${key} -. contextualises .-> ${safeId(d.id)}`); + } + + const chain = cots.evidence.evidence_chain.find((c) => c.decision_id === d.id); + if (!chain) continue; + + for (const claim of chain.claims) { + const cLabel = `${claim.claim_type.toUpperCase()}\\n${truncate(claim.claim_text, 50)}`; + lines.push(` ${safeId(claim.id)}["${cLabel}"]:::claim`); + lines.push(` ${safeId(claim.id)} --> ${safeId(d.id)}`); + + for (const eid of claim.supporting_evidence) { + const item = chain.supporting_evidence.find((e) => e.id === eid); + if (item) { + lines.push(` ${safeId(item.source_id)} -. supports .-> ${safeId(claim.id)}`); + if (item.source_type === "model-generated") { + lines.push(` ${safeId(item.id)}["MODEL-GENERATED"]:::modelGenerated`); + lines.push(` ${safeId(item.id)} -. supports .-> ${safeId(claim.id)}`); + } + } + } + for (const eid of claim.contradicting_evidence) { + const item = chain.contradicting_evidence.find((e) => e.id === eid); + if (item) { + lines.push(` ${safeId(item.source_id)} -. contradicts .-> ${safeId(claim.id)}`); + if (item.source_type === "model-generated") { + lines.push(` ${safeId(item.id)}["MODEL-GENERATED"]:::modelGenerated`); + lines.push(` ${safeId(item.id)} -. contradicts .-> ${safeId(claim.id)}`); + } + } + } + } + } + + return lines.join("\n"); +}; + +export const toContextGraphHtml = (cots: Cots, cotsId?: string): string => { + const mermaid = toContextGraphMermaid(cots); + return ` + + + + Context Graph -- ${cotsId ?? cots.cots_id} + + + + +

Context Graph

+
+
COTS: ${cots.cots_id}
+
Produced: ${cots.produced_at}
+
By: ${cots.produced_by.system_name} v${cots.produced_by.system_version}
+
+
+${mermaid} +
+
+
Parameter
+
Constraint
+
Source
+
Claim
+
Decision
+
Model-generated evidence
+
+ + + +`; +}; + +const safeId = (uri: string): string => { + return uri.replace(/[^a-zA-Z0-9_]/g, "_"); +}; + +const truncate = (s: string, n: number): string => + s.length > n ? s.slice(0, n) + "..." : s; diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 61fdc5f..53a2067 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -11,6 +11,7 @@ import { dirname, join } from "node:path"; import { inspect } from "../src/inspect.js"; import { cite } from "../src/cite.js"; import { toMermaid, toHtml } from "../src/visualize.js"; +import { toContextGraphMermaid, toContextGraphHtml } from "../src/context-graph.js"; import type { Cots } from "../src/cots.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -65,6 +66,80 @@ describe("visualize", () => { }); }); +describe("context-graph (RFC-0004)", () => { + it("renders every context parameter as a node with a set-by edge", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const mermaid = toContextGraphMermaid(cots); + expect(mermaid).toContain("graph LR"); + expect(mermaid).toContain("classDef param"); + expect(mermaid).toContain('param_temperature["temperature = 0.3"]:::param'); + expect(mermaid).toContain("context -. set-by .-> param_temperature"); + expect(mermaid).toContain('param_top_p["top_p = 0.95"]'); + expect(mermaid).toContain('param_tool_budget["tool_budget = 8"]'); + expect(mermaid).toContain('param_retrieval_depth["retrieval_depth = 5"]'); + expect(mermaid).toContain('param_prompt_cache_id["prompt_cache_id = null"]'); + }); + + it("links parameters to decisions with contextualises edges", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const mermaid = toContextGraphMermaid(cots); + const decisionId = cots.decisions[0].id.replace(/[^a-zA-Z0-9_]/g, "_"); + expect(mermaid).toContain(`param_temperature -. contextualises .-> ${decisionId}`); + }); + + it("renders constraints with constrains edges when present", () => { + const cots = loadFixture("legal-summarization.cots.json"); + cots.context.constraints = [ + { id: "urn:source-engineering:constraint:no-medical-advice", type: "scope", scope: "domain", value: "legal-only" }, + ]; + const mermaid = toContextGraphMermaid(cots); + expect(mermaid).toContain("classDef constraint"); + expect(mermaid).toContain("constrains .-> context"); + }); + + it("keeps the evidence edges and marks model-generated items distinctly", () => { + const cots = loadFixture("legal-summarization.cots.json"); + cots.evidence.evidence_chain[0].supporting_evidence[0].source_type = "model-generated"; + const mermaid = toContextGraphMermaid(cots); + expect(mermaid).toContain("-. supports .->"); + expect(mermaid).toContain("-. contradicts .->"); + expect(mermaid).toContain("classDef modelGenerated"); + expect(mermaid).toContain(":::modelGenerated"); + }); + + it("produces a self-contained HTML page titled Context Graph", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const html = toContextGraphHtml(cots); + expect(html).toContain(""); + expect(html).toContain("Context Graph"); + expect(html).toContain("mermaid"); + }); + + it("writes .context-graph.mermaid and .context-graph.html via the CLI", async () => { + const { execFileSync } = await import("node:child_process"); + const { mkdtempSync, readFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const outDir = mkdtempSync(join(tmpdir(), "se-cg-")); + try { + const fixture = resolve(__dirname, "..", "examples", "legal-summarization.cots.json"); + const out = execFileSync( + "node", + [resolve(__dirname, "..", "dist", "cli", "se-trace.js"), "context-graph", fixture, "-o", outDir], + { encoding: "utf-8" } + ); + expect(out).toContain("context-graph.mermaid"); + expect(out).toContain("context-graph.html"); + const mermaid = readFileSync(join(outDir, "legal-summarization.cots.context-graph.mermaid"), "utf-8"); + expect(mermaid).toContain("graph LR"); + expect(mermaid).toContain("context -. set-by .-> param_temperature"); + const html = readFileSync(join(outDir, "legal-summarization.cots.context-graph.html"), "utf-8"); + expect(html).toContain("Context Graph"); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); +}); + describe("validate-runtime", () => { it("reports a Layer 2 Validation record when present", async () => { const cots = loadFixture("legal-summarization.cots.json");