Skip to content
Open
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
25 changes: 25 additions & 0 deletions knowledge-graph-temporal-validity-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Knowledge Graph Temporal Validity Guard

Focused Scientific Knowledge Graph Integration slice for issue #17.

The guard validates graph snapshots before entity pages and AI recommendations are shown. It checks whether nodes, edges, dataset versions, method applicability records, institution affiliations, and recommendation paths are effective on the graph snapshot date and have not expired or drifted to stale versions.

## Run

```bash
npm test
npm run check
npm run demo
npm run video
```

## Reviewer Artifacts

- `reports/temporal-validity-packet.json`
- `reports/temporal-validity-report.md`
- `reports/summary.svg`
- `reports/demo.mp4`

## Scope

This is not an evidence freshness, ontology drift, author disambiguation, recommendation diversity, or accession crosswalk module. It focuses only on effective-date, expiry-date, and version-window validity for graph objects at one snapshot date.
6 changes: 6 additions & 0 deletions knowledge-graph-temporal-validity-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Acceptance Notes

- Dependency-free Node.js implementation.
- Synthetic fixtures only; no private graph data, external ontology services, credentials, network calls, or live graph mutations.
- TDD red check was observed before implementation: `npm --prefix knowledge-graph-temporal-validity-guard test` failed because `./index` did not exist.
- Demo intentionally includes one temporal hold packet and one temporal-ready packet.
76 changes: 76 additions & 0 deletions knowledge-graph-temporal-validity-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const fs = require('node:fs');
const path = require('node:path');
const {analyzeTemporalGraph} = require('./index');
const {temporalRiskGraph, temporalValidGraph} = require('./sample-data');

const reportsDir = path.join(__dirname, 'reports');
fs.mkdirSync(reportsDir, {recursive: true});

const risk = analyzeTemporalGraph(temporalRiskGraph);
const clean = analyzeTemporalGraph(temporalValidGraph);
const packet = {
generatedAt: new Date('2026-05-23T00:00:00.000Z').toISOString(),
guard: 'knowledge-graph-temporal-validity-guard',
results: [risk, clean],
};

fs.writeFileSync(path.join(reportsDir, 'temporal-validity-packet.json'), `${JSON.stringify(packet, null, 2)}\n`);
fs.writeFileSync(path.join(reportsDir, 'temporal-validity-report.md'), renderMarkdown(packet));
fs.writeFileSync(path.join(reportsDir, 'summary.svg'), renderSvg(risk, clean));

console.log(`risk=${risk.status} blockers=${risk.summary.blockers} suppressed=${risk.summary.suppressedRecommendations}`);
console.log(`clean=${clean.status} blockers=${clean.summary.blockers} suppressed=${clean.summary.suppressedRecommendations}`);
console.log(`reports=${reportsDir}`);

function renderMarkdown(data) {
const rows = data.results.map((result) => (
`| ${result.graphId} | ${result.status} | ${result.summary.blockers} | ${result.summary.reviewItems} | ${result.summary.suppressedRecommendations} | ${result.digest} |`
));
const findings = data.results
.flatMap((result) => result.findings.map((finding) => `- ${result.graphId}: ${finding.code} on ${finding.targetType} \`${finding.targetId}\` - ${finding.message}`))
.join('\n');

return `# Knowledge Graph Temporal Validity Guard

This reviewer packet checks whether graph nodes, relationship edges, dataset versions, method applicability windows, author affiliations, and recommendation paths are valid for the graph snapshot date before entity pages or AI recommendations are shown.

| Graph | Status | Blockers | Review items | Suppressed recommendations | Digest |
| --- | --- | ---: | ---: | ---: | --- |
${rows.join('\n')}

## Findings

${findings || '- No temporal findings.'}

## Scope Boundaries

- Synthetic graph fixtures only.
- No network calls, live ontology APIs, private research objects, or graph database mutations.
- Distinct from evidence freshness: this guard checks effective/expiry windows and version validity for graph objects at a specific snapshot date.
`;
}

function renderSvg(risk, clean) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#f8fafc"/>
<rect x="70" y="70" width="1140" height="580" rx="18" fill="#ffffff" stroke="#1f2933" stroke-width="3"/>
<text x="112" y="138" font-family="Arial, sans-serif" font-size="45" font-weight="700" fill="#111827">Knowledge graph temporal validity guard</text>
<text x="112" y="188" font-family="Arial, sans-serif" font-size="25" fill="#374151">Checks effective dates, expiry dates, versions, affiliations, and recommendation paths before graph publication.</text>
<g transform="translate(112 250)">
<rect width="488" height="250" rx="14" fill="#fff1f0" stroke="#b42318" stroke-width="3"/>
<text x="32" y="56" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#b42318">Temporal hold</text>
<text x="32" y="110" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">${risk.graphId}</text>
<text x="32" y="160" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">Blockers: ${risk.summary.blockers}</text>
<text x="32" y="202" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">Suppressed recs: ${risk.summary.suppressedRecommendations}</text>
</g>
<g transform="translate(680 250)">
<rect width="488" height="250" rx="14" fill="#eefaf2" stroke="#147d3f" stroke-width="3"/>
<text x="32" y="56" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#147d3f">Temporal ready</text>
<text x="32" y="110" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">${clean.graphId}</text>
<text x="32" y="160" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">Blockers: ${clean.summary.blockers}</text>
<text x="32" y="202" font-family="Arial, sans-serif" font-size="24" fill="#1f2933">Suppressed recs: ${clean.summary.suppressedRecommendations}</text>
</g>
<text x="112" y="588" font-family="Arial, sans-serif" font-size="24" fill="#4b5563">Reviewer artifacts: JSON packet, Markdown report, SVG summary, H.264 demo video.</text>
</svg>
`;
}
186 changes: 186 additions & 0 deletions knowledge-graph-temporal-validity-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
const crypto = require('node:crypto');

function parseDate(value) {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}

function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}

function digest(value) {
return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`;
}

function makeFinding(code, severity, targetType, targetId, message, evidence = {}) {
return {code, severity, targetType, targetId, message, evidence};
}

function isExpired(record, asOfDate) {
const until = parseDate(record.validUntil);
return Boolean(until && until < asOfDate);
}

function isFuture(record, asOfDate) {
const from = parseDate(record.validFrom);
return Boolean(from && from > asOfDate);
}

function analyzeTemporalGraph(graph) {
const asOfDate = parseDate(graph.asOf);
if (!asOfDate) {
throw new Error('graph.asOf must be a valid YYYY-MM-DD date');
}

const nodes = new Map((graph.nodes || []).map((node) => [node.id, node]));
const edges = new Map((graph.edges || []).map((edge) => [edge.id, edge]));
const findings = [];

for (const node of nodes.values()) {
if (isFuture(node, asOfDate)) {
findings.push(makeFinding(
'NODE_NOT_YET_EFFECTIVE',
'blocker',
'node',
node.id,
`Node ${node.id} is not effective on ${graph.asOf}.`,
{validFrom: node.validFrom},
));
}
if (isExpired(node, asOfDate)) {
findings.push(makeFinding(
'NODE_EXPIRED',
'blocker',
'node',
node.id,
`Node ${node.id} expired before ${graph.asOf}.`,
{validUntil: node.validUntil},
));
}
}

for (const edge of edges.values()) {
const target = nodes.get(edge.to);
const expired = isExpired(edge, asOfDate);
const future = isFuture(edge, asOfDate);
if (future) {
findings.push(makeFinding(
'EDGE_NOT_YET_EFFECTIVE',
'blocker',
'edge',
edge.id,
`Edge ${edge.id} is not effective on ${graph.asOf}.`,
{validFrom: edge.validFrom},
));
}
if (expired) {
if (edge.type === 'affiliated-with') {
findings.push(makeFinding(
'AFFILIATION_EXPIRED',
'review',
'edge',
edge.id,
`Affiliation edge ${edge.id} expired and needs curator review.`,
{validUntil: edge.validUntil},
));
} else {
findings.push(makeFinding(
'EDGE_EXPIRED',
'blocker',
'edge',
edge.id,
`Edge ${edge.id} expired before ${graph.asOf}.`,
{validUntil: edge.validUntil},
));
}
}
if (target && edge.evidenceVersion && target.version && edge.evidenceVersion !== target.version) {
findings.push(makeFinding(
'EVIDENCE_VERSION_DRIFT',
'blocker',
'edge',
edge.id,
`Edge ${edge.id} cites ${edge.evidenceVersion}, but target ${target.id} is ${target.version}.`,
{edgeVersion: edge.evidenceVersion, targetVersion: target.version, targetId: target.id},
));
}
}

const blockingTargets = new Set(
findings
.filter((finding) => finding.severity === 'blocker')
.map((finding) => `${finding.targetType}:${finding.targetId}`),
);

const recommendationDecisions = (graph.recommendations || []).map((recommendation) => {
const reasons = [];
for (const nodeId of recommendation.path || []) {
if (blockingTargets.has(`node:${nodeId}`)) reasons.push(`NODE_BLOCKED:${nodeId}`);
}
for (const edgeId of recommendation.edgeIds || []) {
if (blockingTargets.has(`edge:${edgeId}`)) reasons.push(`EDGE_BLOCKED:${edgeId}`);
}
return {
id: recommendation.id,
title: recommendation.title,
decision: reasons.length ? 'suppress' : 'publish',
reasons,
};
});

const blockers = findings.filter((finding) => finding.severity === 'blocker').length;
const reviewItems = findings.filter((finding) => finding.severity === 'review').length;
const status = blockers ? 'hold_temporal_review' : (reviewItems ? 'needs_curator_review' : 'temporal_ready');
const summary = {
blockers,
reviewItems,
nodeFindings: findings.filter((finding) => finding.targetType === 'node').length,
edgeFindings: findings.filter((finding) => finding.targetType === 'edge').length,
versionDrift: findings.filter((finding) => finding.code === 'EVIDENCE_VERSION_DRIFT').length,
suppressedRecommendations: recommendationDecisions.filter((item) => item.decision === 'suppress').length,
};

return {
graphId: graph.graphId,
asOf: graph.asOf,
status,
summary,
findings,
recommendationDecisions,
curatorActions: buildCuratorActions(findings),
digest: digest({
graphId: graph.graphId,
asOf: graph.asOf,
findings,
recommendationDecisions,
}),
};
}

function buildCuratorActions(findings) {
const actions = new Set();
for (const finding of findings) {
if (finding.code === 'NODE_EXPIRED') actions.add('Retire expired nodes from public entity pages or map them to a current version.');
if (finding.code === 'NODE_NOT_YET_EFFECTIVE') actions.add('Keep future-dated nodes hidden until their effective date.');
if (finding.code === 'EDGE_EXPIRED') actions.add('Remove or replace expired relationship edges before graph recommendation release.');
if (finding.code === 'EDGE_NOT_YET_EFFECTIVE') actions.add('Delay relationship publication until the effective date.');
if (finding.code === 'AFFILIATION_EXPIRED') actions.add('Route expired affiliations to curator review before profile/entity-page display.');
if (finding.code === 'EVIDENCE_VERSION_DRIFT') actions.add('Refresh relationship evidence to match the current target node or dataset version.');
}
return [...actions];
}

module.exports = {
analyzeTemporalGraph,
};
12 changes: 12 additions & 0 deletions knowledge-graph-temporal-validity-guard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "knowledge-graph-temporal-validity-guard",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"test": "node test.js",
"check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check render-video.js && node --check test.js",
"demo": "node demo.js",
"video": "node render-video.js"
}
}
49 changes: 49 additions & 0 deletions knowledge-graph-temporal-validity-guard/render-video.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const fs = require('node:fs');
const path = require('node:path');
const {spawnSync} = require('node:child_process');

const reportsDir = path.join(__dirname, 'reports');
fs.mkdirSync(reportsDir, {recursive: true});

const candidates = [
process.env.FFMPEG_PATH,
path.join(__dirname, '..', '..', '..', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
path.join(__dirname, '..', '..', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
path.join(__dirname, 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
'ffmpeg',
].filter(Boolean);

const ffmpeg = candidates.find((candidate) => candidate === 'ffmpeg' || fs.existsSync(candidate));
if (!ffmpeg) {
throw new Error('ffmpeg not found. Set FFMPEG_PATH or install ffmpeg-static.');
}

const output = path.join(reportsDir, 'demo.mp4');
const vf = [
'drawbox=x=70:y=70:w=1140:h=580:color=white@0.95:t=fill',
'drawbox=x=120:y=130:w=460:h=360:color=0xb42318@0.88:t=fill',
'drawbox=x=700:y=130:w=460:h=360:color=0x147d3f@0.88:t=fill',
'drawbox=x=165:y=535:w=950:h=45:color=0x374151@0.95:t=fill',
'drawbox=x=165:y=535:w=330:h=45:color=0xb42318@0.95:t=fill',
'drawbox=x=500:y=535:w=260:h=45:color=0xf59e0b@0.95:t=fill',
'drawbox=x=760:y=535:w=355:h=45:color=0x147d3f@0.95:t=fill',
'drawbox=x=120:y=510:w=460:h=26:color=0xfef3c7@0.95:t=fill',
'drawbox=x=700:y=510:w=460:h=26:color=0xd1fae5@0.95:t=fill',
].join(',');

const result = spawnSync(ffmpeg, [
'-y',
'-loglevel', 'error',
'-f', 'lavfi',
'-i', 'color=c=0x1f2933:s=1280x720:d=12:r=15',
'-vf', vf,
'-pix_fmt', 'yuv420p',
'-c:v', 'libx264',
output,
], {stdio: 'inherit'});

if (result.status !== 0) {
throw new Error(`ffmpeg failed with status ${result.status}: ${result.error ? result.error.message : 'see ffmpeg output'}`);
}

console.log(output);
Binary file not shown.
21 changes: 21 additions & 0 deletions knowledge-graph-temporal-validity-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading