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
23 changes: 23 additions & 0 deletions knowledge-graph-recommendation-diversity-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Knowledge Graph Recommendation Diversity Guard

This module is a focused Scientific Knowledge Graph Integration slice for issue #17. It validates discovery-mode recommendation sets before researchers see them, so graph suggestions do not collapse into one institution, funder, method, or stale citation cluster.

The guard checks:

- institution, funder, domain, and method concentration
- citation-herd dominance by one highly cited node
- stale evidence dominance
- missing recommendation rationale paths
- too few visible recommendations for exploratory discovery
- per-recommendation curator actions for weak graph suggestions

It uses only Node.js built-ins and synthetic data.

## Run

```bash
node knowledge-graph-recommendation-diversity-guard/test.js
node knowledge-graph-recommendation-diversity-guard/demo.js
```

The demo writes reviewer artifacts to `knowledge-graph-recommendation-diversity-guard/reports/`.
102 changes: 102 additions & 0 deletions knowledge-graph-recommendation-diversity-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const { evaluateRecommendationDiversity } = require('./index');
const { biasedRecommendationSet } = require('./sample-data');

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

const result = evaluateRecommendationDiversity(biasedRecommendationSet);
const packetPath = path.join(reportDir, 'recommendation-diversity-packet.json');
const reportPath = path.join(reportDir, 'recommendation-diversity-report.md');
const svgPath = path.join(reportDir, 'summary.svg');

fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`);

const concentrationRows = result.concentrationChecks
.map((check) => `| ${check.metric} | ${check.result.value} | ${check.result.share} | ${check.limit} |`)
.join('\n');
const itemRows = result.itemResults
.map((item) => `| ${item.id} | ${item.status} | ${item.institution || '-'} | ${item.funder || '-'} | ${item.method || '-'} | ${item.blockers.join(', ') || '-'} |`)
.join('\n');

fs.writeFileSync(
reportPath,
`# Knowledge Graph Recommendation Diversity Report

Status: ${result.status}
Audit digest: ${result.auditDigest}

## Recommendation Set

Title: ${result.set.title}
Audience: ${result.set.audience}
Showable recommendations: ${result.showableRecommendations}/${result.totalRecommendations}

## Concentration Checks

| Metric | Top value | Share | Limit |
| --- | --- | ---: | ---: |
${concentrationRows}

## Recommendation Review

| Recommendation | Status | Institution | Funder | Method | Blockers |
| --- | --- | --- | --- | --- | --- |
${itemRows}

## Citation Herd

- Top cited recommendation: ${result.citationHerd.id}
- Citation share: ${result.citationHerd.share}

## Stale Evidence

- Stale evidence share: ${result.staleEvidenceShare}

## Blockers

${result.blockers.map((blocker) => `- ${blocker}`).join('\n')}

## Curator Actions

${result.curatorActions.map((action) => `- ${action}`).join('\n')}
`,
);

fs.writeFileSync(
svgPath,
`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#f8fafc"/>
<rect x="58" y="52" width="844" height="436" rx="18" fill="#ffffff" stroke="#cbd5e1"/>
<text x="90" y="112" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#0f172a">Knowledge Graph Recommendation Guard</text>
<text x="90" y="154" font-family="Arial, sans-serif" font-size="18" fill="#475569">Status: ${result.status} | Showable: ${result.showableRecommendations}/${result.totalRecommendations}</text>
<g transform="translate(90 202)">
<rect width="234" height="126" rx="12" fill="#fee2e2" stroke="#ef4444"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#991b1b">${result.blockers.length}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#991b1b">diversity blockers</text>
</g>
<g transform="translate(363 202)">
<rect width="234" height="126" rx="12" fill="#fef3c7" stroke="#f59e0b"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#92400e">${result.warnings.length}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#92400e">disclosure warnings</text>
</g>
<g transform="translate(636 202)">
<rect width="234" height="126" rx="12" fill="#dbeafe" stroke="#2563eb"/>
<text x="24" y="52" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#1e40af">${result.staleEvidenceShare}</text>
<text x="24" y="90" font-family="Arial, sans-serif" font-size="17" fill="#1e40af">stale evidence share</text>
</g>
<text x="90" y="390" font-family="Arial, sans-serif" font-size="17" fill="#334155">Top institution share: ${result.concentrationChecks[0].result.share}</text>
<text x="90" y="422" font-family="Arial, sans-serif" font-size="17" fill="#334155">Top funder share: ${result.concentrationChecks[1].result.share}</text>
<text x="90" y="454" font-family="Arial, sans-serif" font-size="17" fill="#334155">Digest: ${result.auditDigest.slice(0, 32)}...</text>
</svg>
`,
);

console.log(`status=${result.status}`);
console.log(`showable=${result.showableRecommendations}/${result.totalRecommendations}`);
console.log(`blockers=${result.blockers.length}`);
console.log(`warnings=${result.warnings.length}`);
console.log(`staleEvidenceShare=${result.staleEvidenceShare}`);
console.log(`auditDigest=${result.auditDigest}`);
console.log(`reports=${reportDir}`);
206 changes: 206 additions & 0 deletions knowledge-graph-recommendation-diversity-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
const crypto = require('crypto');

const DEFAULT_POLICY = {
minRecommendations: 5,
minRationaleEdges: 2,
maxInstitutionShare: 0.5,
maxFunderShare: 0.5,
maxDomainShare: 0.7,
maxMethodShare: 0.5,
maxTopCitationShare: 0.6,
maxStaleEvidenceShare: 0.4,
staleEvidenceBeforeYear: 2023,
};

function asArray(value) {
return Array.isArray(value) ? value : [];
}

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

function digest(value) {
return crypto.createHash('sha256').update(stableJson(value)).digest('hex');
}

function unique(values) {
return [...new Set(values.filter(Boolean))];
}

function ratio(count, total) {
return total === 0 ? 0 : Number((count / total).toFixed(3));
}

function topShare(items, field) {
const counts = new Map();
for (const item of items) {
const value = item[field] || 'unknown';
counts.set(value, (counts.get(value) || 0) + 1);
}
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
const [value, count] = sorted[0] || ['none', 0];
return {
field,
value,
count,
share: ratio(count, items.length),
};
}

function citationShare(items) {
const total = items.reduce((sum, item) => sum + Math.max(0, Number(item.citations || 0)), 0);
const top = items.reduce((winner, item) => (Number(item.citations || 0) > Number(winner.citations || 0) ? item : winner), items[0] || {});
return {
id: top.id || 'none',
citations: Number(top.citations || 0),
share: total === 0 ? 0 : Number((Number(top.citations || 0) / total).toFixed(3)),
};
}

function evaluateRecommendation(item, policy) {
const blockers = [];
const warnings = [];
const actions = [];
const rationaleEdges = asArray(item.rationaleEdges);

if (rationaleEdges.length < policy.minRationaleEdges) {
blockers.push('insufficient_rationale_edges');
actions.push('add_explainable_graph_path');
}

if (!item.institution) {
warnings.push('institution_missing');
actions.push('add_institution_metadata');
}

if (!item.funder) {
warnings.push('funder_missing');
actions.push('add_funder_metadata');
}

if (!item.method) {
warnings.push('method_missing');
actions.push('add_method_metadata');
}

if (Number(item.evidenceYear || 0) < policy.staleEvidenceBeforeYear) {
warnings.push('stale_evidence');
actions.push('refresh_or_label_evidence_age');
}

if (item.suppressed === true) {
blockers.push('already_suppressed_source');
actions.push('replace_suppressed_source');
}

return {
id: item.id,
title: item.title,
domain: item.domain,
institution: item.institution,
funder: item.funder,
method: item.method,
status: blockers.length ? 'hold' : warnings.length ? 'warn' : 'show',
blockers,
warnings,
actions: unique(actions),
};
}

function evaluateRecommendationDiversity(input) {
const policy = { ...DEFAULT_POLICY, ...(input.policy || {}) };
const recommendationSet = input.recommendationSet || {};
const recommendations = asArray(recommendationSet.recommendations);
const itemResults = recommendations.map((item) => evaluateRecommendation(item, policy));
const shownItems = recommendations.filter((item) => {
const result = itemResults.find((candidate) => candidate.id === item.id);
return result && result.status !== 'hold';
});
const blockers = [];
const warnings = [];
const actions = [];

if (shownItems.length < policy.minRecommendations) {
blockers.push('too_few_showable_recommendations');
actions.push('add_more_diverse_candidates');
}

const concentrationChecks = [
{ metric: 'institution', result: topShare(recommendations, 'institution'), limit: policy.maxInstitutionShare },
{ metric: 'funder', result: topShare(recommendations, 'funder'), limit: policy.maxFunderShare },
{ metric: 'domain', result: topShare(recommendations, 'domain'), limit: policy.maxDomainShare },
{ metric: 'method', result: topShare(recommendations, 'method'), limit: policy.maxMethodShare },
];

for (const check of concentrationChecks) {
if (check.result.share > check.limit) {
blockers.push(`${check.metric}_concentration`);
actions.push(`rebalance_${check.metric}_mix`);
}
}

const citation = citationShare(recommendations);
if (citation.share > policy.maxTopCitationShare) {
warnings.push('citation_herd_risk');
actions.push('add_low_citation_or_recent_counterpoints');
}

const staleCount = recommendations.filter((item) => Number(item.evidenceYear || 0) < policy.staleEvidenceBeforeYear).length;
const staleShare = ratio(staleCount, recommendations.length);
if (staleShare > policy.maxStaleEvidenceShare) {
blockers.push('stale_evidence_dominance');
actions.push('refresh_recommendation_evidence');
}

for (const result of itemResults) {
for (const blocker of result.blockers) {
blockers.push(`${result.id}_${blocker}`);
}
for (const warning of result.warnings) {
warnings.push(`${result.id}_${warning}`);
}
for (const action of result.actions) {
actions.push(`${result.id}_${action}`);
}
}

const status = blockers.length ? 'hold' : warnings.length ? 'show_with_disclosures' : 'show';
const packet = {
set: {
id: recommendationSet.id,
title: recommendationSet.title,
audience: recommendationSet.audience,
},
status,
showableRecommendations: shownItems.length,
totalRecommendations: recommendations.length,
concentrationChecks,
citationHerd: citation,
staleEvidenceShare: staleShare,
blockers: unique(blockers),
warnings: unique(warnings),
curatorActions: unique(actions),
itemResults,
};

return {
...packet,
auditDigest: digest(packet),
};
}

module.exports = {
DEFAULT_POLICY,
digest,
evaluateRecommendationDiversity,
};
Binary file not shown.
Loading