Files
awesome-copilot/skills/build-evidence-map/scripts/contract.mjs
T
alsoleg89 37ade4b521 Add source-grounded evidence map skill 🤖🤖🤖 (#2505)
* Add source-grounded evidence map skill

* Bundle offline evidence map validation

* Avoid implicit package execution
2026-08-05 15:17:55 +10:00

518 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const NODE_TYPES = new Set(["position", "claim", "evidence", "unknown"]);
export const RELATIONS = new Set(["supports", "contradicts", "qualifies", "missing"]);
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?Z$/;
const LOCATOR_PATTERNS = [
/\bp(?:age)?\.?\s*\d+(?:\s*[-]\s*\d+)?\b/i,
/§\s*[\p{L}\p{N}][\p{L}\p{N}._-]*/u,
/\bL\d+(?:\s*[-]\s*L?\d+)?\b/i,
/\blines?\s+\d+(?:\s*[-]\s*\d+)?\b/i,
/\b(?:\d{1,2}:)?\d{2}:\d{2}(?:\s*[-]\s*(?:\d{1,2}:)?\d{2}:\d{2})?\b/,
/^(?:section|chapter|heading)\s*(?::|§)\s*\S.{1,}$/i,
];
export class MapValidationError extends Error {
constructor(findings) {
super(`Evidence map is invalid (${findings.length} ${findings.length === 1 ? "finding" : "findings"}).`);
this.name = "MapValidationError";
this.findings = findings;
}
}
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonical(value[key])]),
);
}
export function canonicalJson(map) {
return JSON.stringify(canonical(map));
}
export function receiptPayload(map, sourceSnapshots) {
return {
contract: "doubt-evidence-receipt-v1",
map,
sourceSnapshots,
};
}
function finding(path, rule, message) {
return { path, rule, message };
}
function parseIsoDate(value) {
if (typeof value !== "string") return null;
const match = value.match(ISO_DATE);
if (!match) return null;
const [, year, month, day] = match.map(Number);
const time = Date.UTC(year, month - 1, day);
const date = new Date(time);
if (
date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day
) return null;
return time;
}
function retrievalDate(value) {
const date = parseIsoDate(value);
if (date !== null) return date;
if (typeof value !== "string") return null;
const match = value.match(ISO_UTC_TIMESTAMP);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match.map(Number);
if (hour > 23 || minute > 59 || second > 59) return null;
const dayValue = parseIsoDate(
`${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
);
return dayValue === null ? null : dayValue;
}
function validUtcTimestamp(value) {
if (typeof value !== "string") return false;
const match = value.match(ISO_UTC_TIMESTAMP);
if (!match) return false;
const [, year, month, day, hour, minute, second] = match.map(Number);
if (hour > 23 || minute > 59 || second > 59) return false;
return parseIsoDate(
`${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
) !== null;
}
function boundedLocator(value) {
return typeof value === "string" && LOCATOR_PATTERNS.some((pattern) => pattern.test(value.trim()));
}
function sourceLocation(value) {
return typeof value === "string" && /^(?:https?:\/\/|file:\/\/|\.\.?[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(value);
}
function substantiveExcerpt(value) {
if (typeof value !== "string") return false;
const symbols = value.toLowerCase().match(/[\p{L}\p{N}]/gu) || [];
return new Set(symbols).size >= 6;
}
function reaches(start, target, adjacency, seen = new Set()) {
if (start === target) return true;
if (seen.has(start)) return false;
seen.add(start);
return (adjacency.get(start) || []).some((next) => reaches(next, target, adjacency, seen));
}
export function inspectMapContract(map) {
const findings = [];
if (!map || typeof map !== "object" || Array.isArray(map)) {
return {
findings: [finding("$", "map-type", "The map must be a JSON object.")],
metrics: { claims: 0, contradictions: 0, evidence: 0, sources: 0, unknowns: 0 },
receipt: null,
valid: false,
};
}
for (const key of ["title", "question", "verdict", "updatedAt"]) {
if (!map[key] || typeof map[key] !== "string") {
findings.push(finding(`$.${key}`, "required-field", `${key} must be a non-empty string.`));
}
}
const updatedAt = parseIsoDate(map.updatedAt);
if (typeof map.updatedAt === "string" && updatedAt === null) {
findings.push(
finding("$.updatedAt", "map-date", "updatedAt must be a real calendar date in YYYY-MM-DD format."),
);
}
if (!Array.isArray(map.nodes) || map.nodes.length === 0) {
findings.push(finding("$.nodes", "required-nodes", "nodes must be a non-empty array."));
}
if (!Array.isArray(map.edges)) {
findings.push(finding("$.edges", "required-edges", "edges must be an array."));
}
if (!Array.isArray(map.sources)) {
findings.push(finding("$.sources", "required-sources", "sources must be an array."));
}
const nodes = Array.isArray(map.nodes) ? map.nodes : [];
const edges = Array.isArray(map.edges) ? map.edges : [];
const sources = Array.isArray(map.sources) ? map.sources : [];
const nodeIds = new Set();
const sourceIds = new Set();
for (const [index, node] of nodes.entries()) {
const base = `$.nodes[${index}]`;
if (!node || typeof node !== "object" || Array.isArray(node)) {
findings.push(finding(base, "node-type", "Each node must be an object."));
continue;
}
if (!node.id || typeof node.id !== "string") {
findings.push(finding(`${base}.id`, "node-id", "Each node needs a string id."));
} else if (nodeIds.has(node.id)) {
findings.push(finding(`${base}.id`, "duplicate-node", `Duplicate node id: ${node.id}.`));
} else {
nodeIds.add(node.id);
}
if (!NODE_TYPES.has(node.type)) {
findings.push(
finding(
`${base}.type`,
"node-type",
`Node type must be one of: ${[...NODE_TYPES].join(", ")}.`,
),
);
}
for (const key of ["label", "text"]) {
if (!node[key] || typeof node[key] !== "string") {
findings.push(finding(`${base}.${key}`, "node-copy", `${key} must be a non-empty string.`));
}
}
if (node.confidence != null) {
findings.push(
finding(
`${base}.confidence`,
"false-precision",
"Confidence percentages are not supported; use an unknown or a qualified claim instead.",
),
);
}
}
for (const [index, source] of sources.entries()) {
const base = `$.sources[${index}]`;
if (!source || typeof source !== "object" || Array.isArray(source)) {
findings.push(finding(base, "source-type", "Each source must be an object."));
continue;
}
if (!source.id || typeof source.id !== "string") {
findings.push(finding(`${base}.id`, "source-id", "Each source needs a string id."));
} else if (sourceIds.has(source.id)) {
findings.push(finding(`${base}.id`, "duplicate-source", `Duplicate source id: ${source.id}.`));
} else {
sourceIds.add(source.id);
}
for (const key of ["title", "publisher", "date", "retrievedAt", "url", "locator", "excerpt"]) {
if (!source[key] || typeof source[key] !== "string") {
findings.push(
finding(`${base}.${key}`, "source-field", `${key} must be a non-empty string.`),
);
}
}
const sourceDate = parseIsoDate(source.date);
if (typeof source.date === "string" && sourceDate === null) {
findings.push(
finding(`${base}.date`, "source-date", "Source date must be a real calendar date in YYYY-MM-DD format."),
);
} else if (sourceDate !== null && updatedAt !== null && sourceDate > updatedAt) {
findings.push(
finding(`${base}.date`, "future-source-date", "Source date cannot be later than map.updatedAt."),
);
}
const retrievedAt = retrievalDate(source.retrievedAt);
if (typeof source.retrievedAt === "string" && retrievedAt === null) {
findings.push(
finding(
`${base}.retrievedAt`,
"retrieval-date",
"retrievedAt must be YYYY-MM-DD or an ISO UTC timestamp ending in Z.",
),
);
} else if (retrievedAt !== null && updatedAt !== null && retrievedAt > updatedAt) {
findings.push(
finding(`${base}.retrievedAt`, "future-retrieval", "retrievedAt cannot be later than map.updatedAt."),
);
} else if (retrievedAt !== null && sourceDate !== null && retrievedAt < sourceDate) {
findings.push(
finding(`${base}.retrievedAt`, "retrieval-before-source", "retrievedAt cannot predate the source date."),
);
}
if (typeof source.url === "string" && !sourceLocation(source.url)) {
findings.push(
finding(
`${base}.url`,
"source-url",
"Source location must be http(s), file://, or a relative or absolute local path.",
),
);
}
if (typeof source.locator === "string" && !boundedLocator(source.locator)) {
findings.push(
finding(
`${base}.locator`,
"source-locator",
"Locator must identify a bounded page, section, line range, or timestamp (for example p. 7, § 2.1, L12-L18, Section: Results, or 00:04:31).",
),
);
}
if (typeof source.excerpt === "string" && source.excerpt.trim().length < 40) {
findings.push(
finding(
`${base}.excerpt`,
"thin-excerpt",
"Source excerpt must contain at least 40 characters of checkable context.",
),
);
}
if (typeof source.excerpt === "string" && source.excerpt.trim().length > 500) {
findings.push(
finding(
`${base}.excerpt`,
"oversized-excerpt",
"Keep source excerpts under 500 characters and link to the full source.",
),
);
}
if (
typeof source.excerpt === "string"
&& source.excerpt.trim().length >= 40
&& source.excerpt.trim().length <= 500
&& !substantiveExcerpt(source.excerpt)
) {
findings.push(
finding(
`${base}.excerpt`,
"low-information-excerpt",
"Source excerpt must contain varied, checkable content rather than repeated filler.",
),
);
}
if (source.verification != null) {
const verification = source.verification;
const verificationBase = `${base}.verification`;
if (!verification || typeof verification !== "object" || Array.isArray(verification)) {
findings.push(
finding(verificationBase, "verification-type", "verification must be an object."),
);
} else {
if (verification.status !== "verified") {
findings.push(
finding(`${verificationBase}.status`, "verification-status", "Verification status must be verified."),
);
}
if (verification.method !== "normalized-excerpt-match") {
findings.push(
finding(
`${verificationBase}.method`,
"verification-method",
"Verification method must be normalized-excerpt-match.",
),
);
}
if (!validUtcTimestamp(verification.checkedAt)) {
findings.push(
finding(
`${verificationBase}.checkedAt`,
"verification-time",
"Verification checkedAt must be an ISO UTC timestamp ending in Z.",
),
);
} else if (source.retrievedAt !== verification.checkedAt.slice(0, 10)) {
findings.push(
finding(
`${verificationBase}.checkedAt`,
"verification-retrieval-mismatch",
"A verified source retrievedAt must equal the UTC date in verification.checkedAt.",
),
);
}
for (const key of ["contentSha256", "excerptSha256"]) {
if (typeof verification[key] !== "string" || !/^[a-f0-9]{64}$/.test(verification[key])) {
findings.push(
finding(
`${verificationBase}.${key}`,
"verification-digest",
`${key} must be a lowercase SHA-256 digest.`,
),
);
}
}
if (!["matched", "not-machine-checked"].includes(verification.locatorStatus)) {
findings.push(
finding(
`${verificationBase}.locatorStatus`,
"verification-locator",
"locatorStatus must be matched or not-machine-checked.",
),
);
}
if (typeof verification.finalUrl !== "string" || !sourceLocation(verification.finalUrl)) {
findings.push(
finding(
`${verificationBase}.finalUrl`,
"verification-url",
"finalUrl must be an http(s), file://, or local path source location.",
),
);
}
}
}
}
const incoming = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, 0]));
const adjacency = new Map(nodes.filter((node) => node?.id).map((node) => [node.id, []]));
const uniqueEdges = new Set();
for (const [index, edge] of edges.entries()) {
const base = `$.edges[${index}]`;
if (!edge || typeof edge !== "object" || Array.isArray(edge)) {
findings.push(finding(base, "edge-type", "Each edge must be an object."));
continue;
}
if (!nodeIds.has(edge.from)) {
findings.push(finding(`${base}.from`, "unknown-node", `Unknown from node: ${edge.from}.`));
}
if (!nodeIds.has(edge.to)) {
findings.push(finding(`${base}.to`, "unknown-node", `Unknown to node: ${edge.to}.`));
}
if (edge.from && edge.from === edge.to) {
findings.push(finding(base, "self-edge", `Node ${edge.from} cannot point to itself.`));
}
const edgeKey = `${edge.from}\0${edge.to}\0${edge.relation}`;
if (uniqueEdges.has(edgeKey)) {
findings.push(
finding(base, "duplicate-edge", "Duplicate from/to/relation edges are not allowed."),
);
} else {
uniqueEdges.add(edgeKey);
}
if (!RELATIONS.has(edge.relation)) {
findings.push(
finding(
`${base}.relation`,
"edge-relation",
`Relation must be one of: ${[...RELATIONS].join(", ")}.`,
),
);
}
if (!edge.note || typeof edge.note !== "string") {
findings.push(
finding(`${base}.note`, "edge-note", "Each reasoning edge needs a plain-language note."),
);
}
if (nodeIds.has(edge.to)) incoming.set(edge.to, (incoming.get(edge.to) || 0) + 1);
if (nodeIds.has(edge.from) && nodeIds.has(edge.to) && edge.from !== edge.to) {
adjacency.get(edge.from).push(edge.to);
}
}
for (const [index, node] of nodes.entries()) {
if (!node || typeof node !== "object") continue;
const base = `$.nodes[${index}]`;
if (node.type === "evidence" && !node.sourceId) {
findings.push(
finding(`${base}.sourceId`, "unsourced-evidence", "Evidence nodes require sourceId."),
);
}
if (node.sourceId && !sourceIds.has(node.sourceId)) {
findings.push(
finding(
`${base}.sourceId`,
"unknown-source",
`Node references unknown source: ${node.sourceId}.`,
),
);
}
if (
node.type === "evidence" &&
node.id &&
!edges.some((edge) => edge?.from === node.id)
) {
findings.push(
finding(base, "unused-evidence", "Evidence must participate in at least one reasoning edge."),
);
}
}
for (const [index, source] of sources.entries()) {
if (
source?.id &&
!nodes.some((node) => node?.type === "evidence" && node.sourceId === source.id)
) {
findings.push(
finding(
`$.sources[${index}]`,
"unused-source",
"Every source must be attached to at least one evidence node.",
),
);
}
}
const positions = nodes.filter((node) => node?.type === "position");
if (positions.length !== 1) {
findings.push(
finding("$.nodes", "position-count", "The map must contain exactly one position node."),
);
} else if (!incoming.get(positions[0].id)) {
findings.push(
finding(
`$.nodes[${nodes.indexOf(positions[0])}]`,
"unsupported-position",
"The position needs at least one incoming reasoning edge.",
),
);
} else {
for (const [index, node] of nodes.entries()) {
if (!node?.id || node.id === positions[0].id) continue;
if (!reaches(node.id, positions[0].id, adjacency)) {
findings.push(
finding(
`$.nodes[${index}]`,
"disconnected-node",
`Node ${node.id} must have a directed reasoning path to the position.`,
),
);
}
}
}
const visitState = new Map();
const cyclicNodes = new Set();
function visit(nodeId, stack = []) {
const state = visitState.get(nodeId) || 0;
if (state === 1) {
for (const member of stack.slice(stack.indexOf(nodeId))) cyclicNodes.add(member);
return;
}
if (state === 2) return;
visitState.set(nodeId, 1);
for (const next of adjacency.get(nodeId) || []) visit(next, [...stack, nodeId]);
visitState.set(nodeId, 2);
}
for (const nodeId of nodeIds) visit(nodeId);
for (const nodeId of cyclicNodes) {
const index = nodes.findIndex((node) => node?.id === nodeId);
findings.push(
finding(`$.nodes[${index}]`, "reasoning-cycle", `Node ${nodeId} participates in a reasoning cycle.`),
);
}
const metrics = {
claims: nodes.filter((node) => node?.type === "claim").length,
contradictions: new Set(
edges
.filter((edge) => edge?.relation === "contradicts")
.map((edge) => `${edge.from}\0${edge.to}\0${edge.relation}`),
).size,
evidence: nodes.filter((node) => node?.type === "evidence").length,
sources: sources.length,
unknowns: nodes.filter((node) => node?.type === "unknown").length,
};
return {
findings,
metrics,
receipt: null,
valid: findings.length === 0,
};
}
export function validateMapContract(map) {
const result = inspectMapContract(map);
if (!result.valid) throw new MapValidationError(result.findings);
return result;
}