Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/cli/se-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* - inspect <file> Print a human-readable summary of a COTS.
* - cite <file> <decision-id> Print the evidence chain for a specific decision.
* - visualize <file> Render a COTS as a Reasoning Roadmap (Mermaid + HTML).
* - context-graph <file> Render a COTS's Context + Evidence as a Context Graph (RFC-0004).
*/

import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
Expand All @@ -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
Expand Down Expand Up @@ -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>", "Path to a COTS JSON file")
.option("-o, --out <dir>", "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();
135 changes: 135 additions & 0 deletions src/context-graph.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Context Graph -- ${cotsId ?? cots.cots_id}</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 2rem; color: #1f2937; }
h1 { font-size: 1.5rem; }
.meta { color: #6b7280; font-size: 0.875rem; margin-bottom: 1.5rem; }
.mermaid { background: #ffffff; padding: 1.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; }
.legend { display: flex; gap: 1rem; margin-top: 1rem; font-size: 0.875rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 0.5rem; }
.swatch { display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; }
</style>
</head>
<body>
<h1>Context Graph</h1>
<div class="meta">
<div>COTS: ${cots.cots_id}</div>
<div>Produced: ${cots.produced_at}</div>
<div>By: ${cots.produced_by.system_name} v${cots.produced_by.system_version}</div>
</div>
<div class="mermaid">
${mermaid}
</div>
<div class="legend">
<div class="legend-item"><span class="swatch" style="background:#ede9fe"></span>Parameter</div>
<div class="legend-item"><span class="swatch" style="background:#ffedd5"></span>Constraint</div>
<div class="legend-item"><span class="swatch" style="background:#fef3c7"></span>Source</div>
<div class="legend-item"><span class="swatch" style="background:#dbeafe"></span>Claim</div>
<div class="legend-item"><span class="swatch" style="background:#dcfce7"></span>Decision</div>
<div class="legend-item"><span class="swatch" style="background:#fee2e2"></span>Model-generated evidence</div>
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'default' });
</script>
</body>
</html>
`;
};

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;
75 changes: 75 additions & 0 deletions tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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("<!DOCTYPE html>");
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");
Expand Down
Loading