@inflexa-ai/harness 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/agents/conversation-agent.d.ts +3 -0
  2. package/dist/agents/conversation-agent.js +5 -2
  3. package/dist/agents/sandbox/shared.d.ts +3 -0
  4. package/dist/agents/sandbox/shared.js +5 -0
  5. package/dist/agents/sandbox/types.d.ts +1 -1
  6. package/dist/app/chat-turn.d.ts +2 -0
  7. package/dist/app/chat-turn.js +11 -2
  8. package/dist/app/synthesize-run.d.ts +3 -0
  9. package/dist/app/synthesize-run.js +2 -1
  10. package/dist/citations/__fixtures__/resolver.d.ts +3 -0
  11. package/dist/citations/__fixtures__/resolver.js +9 -0
  12. package/dist/citations/aggregate.d.ts +3 -0
  13. package/dist/citations/aggregate.js +44 -0
  14. package/dist/citations/clients/arxiv.d.ts +6 -0
  15. package/dist/citations/clients/arxiv.js +34 -0
  16. package/dist/citations/clients/common.d.ts +11 -0
  17. package/dist/citations/clients/common.js +43 -0
  18. package/dist/citations/clients/crossref.d.ts +8 -0
  19. package/dist/citations/clients/crossref.js +98 -0
  20. package/dist/citations/clients/doi-registry.d.ts +3 -0
  21. package/dist/citations/clients/doi-registry.js +95 -0
  22. package/dist/citations/clients/pubmed.d.ts +6 -0
  23. package/dist/citations/clients/pubmed.js +83 -0
  24. package/dist/citations/clients/semantic-scholar.d.ts +6 -0
  25. package/dist/citations/clients/semantic-scholar.js +60 -0
  26. package/dist/citations/compare.d.ts +9 -0
  27. package/dist/citations/compare.js +72 -0
  28. package/dist/citations/match.d.ts +11 -0
  29. package/dist/citations/match.js +173 -0
  30. package/dist/citations/normalize.d.ts +31 -0
  31. package/dist/citations/normalize.js +187 -0
  32. package/dist/citations/plan.d.ts +2 -0
  33. package/dist/citations/plan.js +59 -0
  34. package/dist/citations/resolve.d.ts +28 -0
  35. package/dist/citations/resolve.js +238 -0
  36. package/dist/citations/types.d.ts +583 -0
  37. package/dist/citations/types.js +129 -0
  38. package/dist/contracts/chat-events.d.ts +16 -0
  39. package/dist/contracts/message.d.ts +13 -8
  40. package/dist/contracts/schemas/chat-events.d.ts +7 -0
  41. package/dist/contracts/schemas/chat-events.js +2 -0
  42. package/dist/contracts/schemas/chat-parts.d.ts +8 -8
  43. package/dist/contracts/target-dossier.d.ts +20 -20
  44. package/dist/execution/run-synthesis.d.ts +3 -0
  45. package/dist/execution/run-synthesis.js +1 -0
  46. package/dist/index.d.ts +14 -10
  47. package/dist/index.js +20 -16
  48. package/dist/lib/cache.d.ts +8 -0
  49. package/dist/lib/cache.js +35 -0
  50. package/dist/lib/rate-limit.d.ts +35 -0
  51. package/dist/lib/rate-limit.js +121 -0
  52. package/dist/literature/sources/arxiv.d.ts +22 -0
  53. package/dist/literature/sources/arxiv.js +103 -0
  54. package/dist/literature/sources/http.d.ts +28 -0
  55. package/dist/literature/sources/http.js +97 -0
  56. package/dist/literature/sources/pubmed.d.ts +68 -0
  57. package/dist/literature/sources/pubmed.js +266 -0
  58. package/dist/literature/sources/semantic-scholar.d.ts +24 -0
  59. package/dist/literature/sources/semantic-scholar.js +71 -0
  60. package/dist/memory/content-to-cortex.d.ts +12 -11
  61. package/dist/memory/content-to-cortex.js +17 -14
  62. package/dist/memory/conversation-display-backfill.d.ts +25 -0
  63. package/dist/memory/conversation-display-backfill.js +97 -0
  64. package/dist/memory/conversation-display-recorder.d.ts +17 -0
  65. package/dist/memory/conversation-display-recorder.js +156 -0
  66. package/dist/memory/conversation-display-replay.d.ts +19 -0
  67. package/dist/memory/conversation-display-replay.js +39 -0
  68. package/dist/memory/conversation-display-storage.d.ts +86 -0
  69. package/dist/memory/conversation-display-storage.js +168 -0
  70. package/dist/memory/reconstruct-cards.d.ts +7 -1
  71. package/dist/memory/reconstruct-cards.js +5 -1
  72. package/dist/memory/thread-history.d.ts +46 -11
  73. package/dist/memory/thread-history.js +55 -13
  74. package/dist/memory/thread-store.d.ts +142 -25
  75. package/dist/memory/thread-store.js +228 -40
  76. package/dist/prompts/literature-reviewer.d.ts +1 -1
  77. package/dist/prompts/literature-reviewer.js +2 -0
  78. package/dist/runtime/assemble.d.ts +50 -4
  79. package/dist/runtime/assemble.js +47 -3
  80. package/dist/runtime/boot.d.ts +8 -2
  81. package/dist/runtime/boot.js +20 -2
  82. package/dist/state/init.js +50 -1
  83. package/dist/state/schema.d.ts +2 -2
  84. package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
  85. package/dist/tools/bio/keys.d.ts +1 -0
  86. package/dist/tools/bio/pubmed.js +4 -4
  87. package/dist/tools/lib/ncbi-utils.d.ts +3 -58
  88. package/dist/tools/lib/ncbi-utils.js +2 -286
  89. package/dist/tools/lib/pubmed-client.d.ts +5 -12
  90. package/dist/tools/lib/pubmed-client.js +20 -79
  91. package/dist/tools/research/generate-analogy-report.js +2 -2
  92. package/dist/tools/research/index.d.ts +1 -0
  93. package/dist/tools/research/index.js +1 -0
  94. package/dist/tools/research/inspect-run.d.ts +1 -1
  95. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  96. package/dist/tools/research/literature-reviewer.js +2 -0
  97. package/dist/tools/research/resolve-citation.d.ts +3 -0
  98. package/dist/tools/research/resolve-citation.js +15 -0
  99. package/dist/tools/research/search-arxiv.d.ts +3 -19
  100. package/dist/tools/research/search-arxiv.js +9 -105
  101. package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
  102. package/dist/tools/research/search-semantic-scholar.js +29 -88
  103. package/dist/tools/sandbox/list-available-refs.js +1 -2
  104. package/dist/workflows/execute-analysis.d.ts +3 -0
  105. package/dist/workflows/execute-analysis.js +1 -0
  106. package/dist/workflows/sandbox-step.d.ts +5 -0
  107. package/dist/workflows/sandbox-step.js +1 -0
  108. package/dist/workflows/target-assessment/phase4-assemble.d.ts +2 -2
  109. package/dist/workflows/target-assessment/schemas.d.ts +2 -2
  110. package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +1 -1
  111. package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
  112. package/package.json +1 -1
@@ -0,0 +1,83 @@
1
+ import { createPubmedSource } from "../../literature/sources/pubmed.js";
2
+ import { parseYear, sourceOutcome } from "./common.js";
3
+ function pubmedRecord(article) {
4
+ const year = parseYear(article.year);
5
+ return {
6
+ source: "pubmed",
7
+ sourceRecordId: article.pmid,
8
+ identifiers: { pmid: article.pmid, ...(article.doi ? { doi: article.doi.toLocaleLowerCase("en-US") } : {}) },
9
+ ...(article.title ? { title: article.title } : {}),
10
+ ...(article.authors.length === 0 ? {} : { authors: article.authors }),
11
+ ...(year === undefined ? {} : { year }),
12
+ ...(article.journal ? { venue: article.journal } : {}),
13
+ url: `https://pubmed.ncbi.nlm.nih.gov/${article.pmid}/`,
14
+ };
15
+ }
16
+ export function createPubmedCitationClient(options = {}) {
17
+ const source = createPubmedSource(options);
18
+ async function resolve(request, signal) {
19
+ if (!request.plan.applicable)
20
+ return sourceOutcome("pubmed", request.plan.operation, "not_applicable", 0, [], request.plan.reason);
21
+ let requestCount = 0;
22
+ let pmids;
23
+ if (request.plan.operation === "pubmed_exact") {
24
+ pmids = [request.normalized.identifiers.pmid];
25
+ }
26
+ else if (request.plan.operation === "pubmed_structured") {
27
+ requestCount += 1;
28
+ const match = await source.matchCitation({
29
+ ...(request.input.venue === undefined ? {} : { venue: request.input.venue }),
30
+ ...(request.input.year === undefined ? {} : { year: request.input.year }),
31
+ ...(request.input.volume === undefined ? {} : { volume: request.input.volume }),
32
+ ...(request.input.firstPage === undefined ? {} : { firstPage: request.input.firstPage }),
33
+ ...(request.input.authors?.[0] === undefined ? {} : { firstAuthor: request.input.authors[0] }),
34
+ key: "citation-0",
35
+ }, signal);
36
+ if (match.status !== "ok")
37
+ return sourceOutcome("pubmed", request.plan.operation, match.status, requestCount, [], match.detail);
38
+ pmids = match.value;
39
+ }
40
+ else {
41
+ requestCount += 1;
42
+ const term = request.plan.operation === "pubmed_doi" ? `${request.normalized.identifiers.doi}[AID]` : `${request.input.title ?? ""}[Title]`;
43
+ const search = await source.searchIds(term, options.maxCandidates ?? 5, signal);
44
+ if (search.status !== "ok")
45
+ return sourceOutcome("pubmed", request.plan.operation, search.status, requestCount, [], search.detail);
46
+ pmids = search.value;
47
+ }
48
+ if (pmids.length === 0)
49
+ return sourceOutcome("pubmed", request.plan.operation, "no_data", requestCount);
50
+ requestCount += 1;
51
+ const fetched = await source.fetchArticles(pmids, signal);
52
+ if (fetched.status !== "ok")
53
+ return sourceOutcome("pubmed", request.plan.operation, fetched.status, requestCount, [], fetched.detail);
54
+ const records = fetched.value.map(pubmedRecord);
55
+ return sourceOutcome("pubmed", request.plan.operation, records.length === 0 ? "no_data" : "ok", requestCount, records);
56
+ }
57
+ async function resolveMany(requests, signal) {
58
+ const results = Array.from({ length: requests.length });
59
+ const exact = requests.flatMap((request, index) => request.plan.applicable && request.plan.operation === "pubmed_exact" && request.normalized.identifiers.pmid !== undefined
60
+ ? [{ index, request, pmid: request.normalized.identifiers.pmid }]
61
+ : []);
62
+ for (let offset = 0; offset < exact.length; offset += 20) {
63
+ const chunk = exact.slice(offset, offset + 20);
64
+ const fetched = await source.fetchArticles(chunk.map(({ pmid }) => pmid), signal);
65
+ if (fetched.status !== "ok") {
66
+ for (const entry of chunk)
67
+ results[entry.index] = sourceOutcome("pubmed", entry.request.plan.operation, fetched.status, 1, [], fetched.detail);
68
+ continue;
69
+ }
70
+ const byPmid = new Map(fetched.value.map((article) => [article.pmid, pubmedRecord(article)]));
71
+ for (const entry of chunk) {
72
+ const record = byPmid.get(entry.pmid);
73
+ results[entry.index] = sourceOutcome("pubmed", entry.request.plan.operation, record === undefined ? "no_data" : "ok", 1, record === undefined ? [] : [record]);
74
+ }
75
+ }
76
+ await Promise.all(requests.map(async (request, index) => {
77
+ if (results[index] === undefined)
78
+ results[index] = await resolve(request, signal);
79
+ }));
80
+ return results.map((result) => result);
81
+ }
82
+ return { source: "pubmed", resolve, resolveMany };
83
+ }
@@ -0,0 +1,6 @@
1
+ import type { CitationSourceClient } from "../types.js";
2
+ import { type SemanticScholarSourceOptions } from "../../literature/sources/semantic-scholar.js";
3
+ export interface SemanticScholarCitationClientOptions extends SemanticScholarSourceOptions {
4
+ readonly maxCandidates?: number;
5
+ }
6
+ export declare function createSemanticScholarCitationClient(options?: SemanticScholarCitationClientOptions): CitationSourceClient;
@@ -0,0 +1,60 @@
1
+ import { createSemanticScholarSource } from "../../literature/sources/semantic-scholar.js";
2
+ import { firstNonEmpty, sourceOutcome } from "./common.js";
3
+ function externalId(value, key) {
4
+ const found = value?.[key];
5
+ return found?.trim() ? found.trim() : undefined;
6
+ }
7
+ function semanticRecord(paper) {
8
+ const doi = externalId(paper.externalIds, "DOI")?.toLocaleLowerCase("en-US");
9
+ const pmid = externalId(paper.externalIds, "PubMed");
10
+ const arxiv = externalId(paper.externalIds, "ArXiv")?.toLocaleLowerCase("en-US");
11
+ const corpusId = firstNonEmpty([externalId(paper.externalIds, "CorpusId"), paper.id]);
12
+ return {
13
+ source: "semantic_scholar",
14
+ sourceRecordId: paper.id,
15
+ identifiers: {
16
+ ...(doi === undefined ? {} : { doi }),
17
+ ...(pmid === undefined ? {} : { pmid }),
18
+ ...(arxiv === undefined ? {} : { arxiv }),
19
+ ...(corpusId === undefined ? {} : { corpusId }),
20
+ },
21
+ ...(paper.title === undefined ? {} : { title: paper.title }),
22
+ ...(paper.authors.length === 0 ? {} : { authors: paper.authors }),
23
+ ...(paper.year === undefined ? {} : { year: paper.year }),
24
+ ...(paper.venue === undefined ? {} : { venue: paper.venue }),
25
+ ...(paper.url === undefined ? {} : { url: paper.url }),
26
+ };
27
+ }
28
+ export function createSemanticScholarCitationClient(options = {}) {
29
+ const source = createSemanticScholarSource(options);
30
+ return {
31
+ source: "semantic_scholar",
32
+ async resolve(request, signal) {
33
+ if (!request.plan.applicable)
34
+ return sourceOutcome("semantic_scholar", request.plan.operation, "not_applicable", 0, [], request.plan.reason);
35
+ const identifier = request.normalized.identifiers.doi
36
+ ? `DOI:${request.normalized.identifiers.doi}`
37
+ : request.normalized.identifiers.pmid
38
+ ? `PMID:${request.normalized.identifiers.pmid}`
39
+ : request.normalized.identifiers.arxiv
40
+ ? `ARXIV:${request.normalized.identifiers.arxiv}`
41
+ : undefined;
42
+ const exact = request.plan.operation === "semantic_scholar_identifier" && identifier !== undefined;
43
+ let papers;
44
+ if (exact) {
45
+ const result = await source.lookupIdentifier(identifier, signal);
46
+ if (result.status !== "ok")
47
+ return sourceOutcome("semantic_scholar", request.plan.operation, result.status, 1, [], result.detail);
48
+ papers = [result.value];
49
+ }
50
+ else {
51
+ const result = await source.search(request.input.title ?? request.input.citation, options.maxCandidates ?? 5, signal);
52
+ if (result.status !== "ok")
53
+ return sourceOutcome("semantic_scholar", request.plan.operation, result.status, 1, [], result.detail);
54
+ papers = result.value;
55
+ }
56
+ const records = papers.map(semanticRecord);
57
+ return sourceOutcome("semantic_scholar", request.plan.operation, records.length === 0 ? "no_data" : "ok", 1, records);
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,9 @@
1
+ import type { CitationCandidateCluster, CitationConflict, CitationFieldComparison, CitationInput } from "./types.js";
2
+ /**
3
+ * Stamped onto every field comparison so a consumer can tell which rules
4
+ * produced it. Bump it when the published comparison semantics change — a
5
+ * revision made before these rules ever shipped is not a new version.
6
+ */
7
+ export declare const CITATION_COMPARISON_RULE_VERSION = "citation-compare-v1";
8
+ export declare function compareSuppliedMetadata(input: CitationInput, selected: CitationCandidateCluster | undefined): CitationFieldComparison[];
9
+ export declare function detectCitationConflicts(clusters: readonly CitationCandidateCluster[]): CitationConflict[];
@@ -0,0 +1,72 @@
1
+ import { authorNamesMatch, normalizeAuthor, normalizeComparable } from "./normalize.js";
2
+ /**
3
+ * Stamped onto every field comparison so a consumer can tell which rules
4
+ * produced it. Bump it when the published comparison semantics change — a
5
+ * revision made before these rules ever shipped is not a new version.
6
+ */
7
+ export const CITATION_COMPARISON_RULE_VERSION = "citation-compare-v1";
8
+ const FIELDS = ["title", "authors", "year", "venue", "volume", "firstPage"];
9
+ function recordValue(record, field) {
10
+ return record[field];
11
+ }
12
+ function normalizedValue(field, value) {
13
+ if (field === "authors")
14
+ return value.map(normalizeAuthor).sort().join("|");
15
+ if (field === "year")
16
+ return String(value);
17
+ return normalizeComparable(String(value));
18
+ }
19
+ function authorMatch(supplied, source) {
20
+ return supplied.every((author) => source.some((candidate) => authorNamesMatch(author, candidate)));
21
+ }
22
+ function fieldMatches(field, supplied, source) {
23
+ if (field === "authors")
24
+ return authorMatch(supplied, source);
25
+ if (field === "year")
26
+ return Math.abs(Number(supplied) - Number(source)) <= 1;
27
+ if (field === "title" || field === "venue") {
28
+ const left = new Set(normalizeComparable(String(supplied)).split(" ").filter(Boolean));
29
+ const right = new Set(normalizeComparable(String(source)).split(" ").filter(Boolean));
30
+ if (left.size === 0 || right.size === 0)
31
+ return false;
32
+ let shared = 0;
33
+ for (const token of left)
34
+ if (right.has(token))
35
+ shared += 1;
36
+ return (2 * shared) / (left.size + right.size) >= 0.92;
37
+ }
38
+ return normalizedValue(field, supplied) === normalizedValue(field, source);
39
+ }
40
+ function sourceValues(cluster, field) {
41
+ return cluster.records.flatMap((record) => {
42
+ const value = recordValue(record, field);
43
+ return value === undefined ? [] : [{ source: record.source, value }];
44
+ });
45
+ }
46
+ export function compareSuppliedMetadata(input, selected) {
47
+ if (selected === undefined)
48
+ return [];
49
+ return FIELDS.flatMap((field) => {
50
+ const supplied = input[field];
51
+ if (supplied === undefined)
52
+ return [];
53
+ const values = sourceValues(selected, field);
54
+ const status = values.length === 0 ? "not_compared" : values.every(({ value }) => fieldMatches(field, supplied, value)) ? "match" : "mismatch";
55
+ return [
56
+ {
57
+ field,
58
+ status,
59
+ supplied,
60
+ sourceValues: values,
61
+ ruleVersion: CITATION_COMPARISON_RULE_VERSION,
62
+ },
63
+ ];
64
+ });
65
+ }
66
+ export function detectCitationConflicts(clusters) {
67
+ return clusters.flatMap((cluster) => FIELDS.flatMap((field) => {
68
+ const values = sourceValues(cluster, field);
69
+ const unique = new Set(values.map(({ value }) => normalizedValue(field, value)));
70
+ return unique.size > 1 ? [{ field, values }] : [];
71
+ }));
72
+ }
@@ -0,0 +1,11 @@
1
+ import type { CitationCandidateCluster, CitationInput, CitationRecord, NormalizedCitation } from "./types.js";
2
+ export interface CitationMatchConfig {
3
+ readonly minimumScore: number;
4
+ readonly separationMargin: number;
5
+ readonly clusterSimilarity: number;
6
+ readonly relatedVersionSimilarity: number;
7
+ }
8
+ export declare const DEFAULT_MATCH_CONFIG: CitationMatchConfig;
9
+ export declare function tokenSimilarity(left: string, right: string): number;
10
+ export declare function clusterCitationRecords(input: CitationInput, normalized: NormalizedCitation, records: readonly CitationRecord[], config?: CitationMatchConfig): CitationCandidateCluster[];
11
+ export declare function selectCitationCluster(clusters: readonly CitationCandidateCluster[], config?: CitationMatchConfig): CitationCandidateCluster | undefined;
@@ -0,0 +1,173 @@
1
+ import { createHash } from "node:crypto";
2
+ import { authorNamesMatch, normalizeAuthor, normalizeComparable } from "./normalize.js";
3
+ export const DEFAULT_MATCH_CONFIG = {
4
+ minimumScore: 0.72,
5
+ separationMargin: 0.08,
6
+ clusterSimilarity: 0.9,
7
+ relatedVersionSimilarity: 0.9,
8
+ };
9
+ function tokens(value) {
10
+ return new Set(normalizeComparable(value)
11
+ .split(" ")
12
+ .filter((token) => token.length > 1));
13
+ }
14
+ export function tokenSimilarity(left, right) {
15
+ const a = tokens(left);
16
+ const b = tokens(right);
17
+ if (a.size === 0 || b.size === 0)
18
+ return 0;
19
+ let intersection = 0;
20
+ for (const token of a)
21
+ if (b.has(token))
22
+ intersection += 1;
23
+ const dice = (2 * intersection) / (a.size + b.size);
24
+ const containment = intersection / Math.min(a.size, b.size);
25
+ return Math.max(dice, containment * 0.96);
26
+ }
27
+ function normalizedAuthors(authors) {
28
+ return [...new Set((authors ?? []).map(normalizeAuthor).filter(Boolean))];
29
+ }
30
+ function authorSimilarity(left, right) {
31
+ const a = normalizedAuthors(left);
32
+ const b = normalizedAuthors(right);
33
+ if (a.length === 0 || b.length === 0)
34
+ return 0;
35
+ const intersection = a.filter((author) => b.some((candidate) => authorNamesMatch(author, candidate))).length;
36
+ return intersection / Math.min(a.length, b.length);
37
+ }
38
+ function identifierEntries(identifiers) {
39
+ return Object.entries(identifiers).flatMap(([kind, value]) => (value === undefined ? [] : [`${kind}:${value.toLocaleLowerCase("en-US")}`]));
40
+ }
41
+ function sharesStrongIdentifier(left, right) {
42
+ const rightIds = new Set(identifierEntries(right.identifiers));
43
+ return identifierEntries(left.identifiers).some((identifier) => rightIds.has(identifier));
44
+ }
45
+ function hasStrongIdentifier(record) {
46
+ return identifierEntries(record.identifiers).length > 0;
47
+ }
48
+ function recordSimilarity(left, right) {
49
+ const weighted = [];
50
+ if (left.title && right.title)
51
+ weighted.push([tokenSimilarity(left.title, right.title), 0.7]);
52
+ if (left.authors && right.authors)
53
+ weighted.push([authorSimilarity(left.authors, right.authors), 0.2]);
54
+ if (left.year !== undefined && right.year !== undefined)
55
+ weighted.push([left.year === right.year ? 1 : Math.abs(left.year - right.year) === 1 ? 0.5 : 0, 0.1]);
56
+ const weight = weighted.reduce((sum, [, value]) => sum + value, 0);
57
+ return weight === 0 ? 0 : weighted.reduce((sum, [score, value]) => sum + score * value, 0) / weight;
58
+ }
59
+ function stableClusterId(records) {
60
+ const identity = records
61
+ .map((record) => `${record.source}:${record.sourceRecordId}`)
62
+ .sort()
63
+ .join("|");
64
+ return `cit-${createHash("sha256").update(identity).digest("hex").slice(0, 16)}`;
65
+ }
66
+ class UnionFind {
67
+ parent;
68
+ constructor(size) {
69
+ this.parent = Array.from({ length: size }, (_, index) => index);
70
+ }
71
+ find(index) {
72
+ const parent = this.parent[index];
73
+ if (parent === index)
74
+ return index;
75
+ const root = this.find(parent);
76
+ this.parent[index] = root;
77
+ return root;
78
+ }
79
+ union(left, right) {
80
+ const leftRoot = this.find(left);
81
+ const rightRoot = this.find(right);
82
+ if (leftRoot !== rightRoot)
83
+ this.parent[rightRoot] = leftRoot;
84
+ }
85
+ }
86
+ function clusterScore(input, normalized, records) {
87
+ const inputIds = new Set(identifierEntries(normalized.identifiers));
88
+ if (records.some((record) => identifierEntries(record.identifiers).some((identifier) => inputIds.has(identifier))))
89
+ return 1;
90
+ let best = 0;
91
+ for (const record of records) {
92
+ const signals = [];
93
+ if (record.title)
94
+ signals.push([tokenSimilarity(normalized.supplied.title ?? normalized.query, record.title), 0.65]);
95
+ if (normalized.supplied.authors && record.authors)
96
+ signals.push([authorSimilarity(normalized.supplied.authors, record.authors), 0.2]);
97
+ if (normalized.supplied.year !== undefined && record.year !== undefined) {
98
+ signals.push([normalized.supplied.year === record.year ? 1 : Math.abs(normalized.supplied.year - record.year) === 1 ? 0.5 : 0, 0.1]);
99
+ }
100
+ if (normalized.supplied.venue && record.venue)
101
+ signals.push([tokenSimilarity(normalized.supplied.venue, record.venue), 0.05]);
102
+ const weight = signals.reduce((sum, [, value]) => sum + value, 0);
103
+ const score = weight === 0 ? 0 : signals.reduce((sum, [value, signalWeight]) => sum + value * signalWeight, 0) / weight;
104
+ best = Math.max(best, score);
105
+ }
106
+ return best;
107
+ }
108
+ function containsId(cluster, key) {
109
+ return cluster.records.some((record) => record.identifiers[key] !== undefined);
110
+ }
111
+ function addRelatedVersions(clusters, config) {
112
+ for (let left = 0; left < clusters.length; left += 1) {
113
+ for (let right = left + 1; right < clusters.length; right += 1) {
114
+ const a = clusters[left];
115
+ const b = clusters[right];
116
+ const similarity = Math.max(...a.records.flatMap((one) => b.records.map((two) => recordSimilarity(one, two))));
117
+ if (similarity < config.relatedVersionSimilarity)
118
+ continue;
119
+ const aArxiv = containsId(a, "arxiv");
120
+ const bArxiv = containsId(b, "arxiv");
121
+ const aDoi = containsId(a, "doi");
122
+ const bDoi = containsId(b, "doi");
123
+ if (aArxiv && bDoi) {
124
+ a.relations.push({ clusterId: b.id, kind: "published_as" });
125
+ b.relations.push({ clusterId: a.id, kind: "preprint_of" });
126
+ }
127
+ else if (bArxiv && aDoi) {
128
+ a.relations.push({ clusterId: b.id, kind: "preprint_of" });
129
+ b.relations.push({ clusterId: a.id, kind: "published_as" });
130
+ }
131
+ else {
132
+ a.relations.push({ clusterId: b.id, kind: "related_version" });
133
+ b.relations.push({ clusterId: a.id, kind: "related_version" });
134
+ }
135
+ }
136
+ }
137
+ }
138
+ export function clusterCitationRecords(input, normalized, records, config = DEFAULT_MATCH_CONFIG) {
139
+ const union = new UnionFind(records.length);
140
+ for (let left = 0; left < records.length; left += 1) {
141
+ for (let right = left + 1; right < records.length; right += 1) {
142
+ const a = records[left];
143
+ const b = records[right];
144
+ if (sharesStrongIdentifier(a, b))
145
+ union.union(left, right);
146
+ else if ((!hasStrongIdentifier(a) || !hasStrongIdentifier(b)) && recordSimilarity(a, b) >= config.clusterSimilarity)
147
+ union.union(left, right);
148
+ }
149
+ }
150
+ const grouped = new Map();
151
+ for (let index = 0; index < records.length; index += 1) {
152
+ const root = union.find(index);
153
+ grouped.set(root, [...(grouped.get(root) ?? []), records[index]]);
154
+ }
155
+ const clusters = [...grouped.values()].map((members) => ({
156
+ id: stableClusterId(members),
157
+ score: clusterScore(input, normalized, members),
158
+ records: members,
159
+ relations: [],
160
+ }));
161
+ clusters.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
162
+ addRelatedVersions(clusters, config);
163
+ return clusters;
164
+ }
165
+ export function selectCitationCluster(clusters, config = DEFAULT_MATCH_CONFIG) {
166
+ const first = clusters[0];
167
+ if (first === undefined || first.score < config.minimumScore)
168
+ return undefined;
169
+ const second = clusters[1];
170
+ if (second !== undefined && first.score - second.score < config.separationMargin)
171
+ return undefined;
172
+ return first;
173
+ }
@@ -0,0 +1,31 @@
1
+ import { type CitationInput, type NormalizedCitation } from "./types.js";
2
+ export declare function normalizeComparable(value: string): string;
3
+ export declare function normalizeAuthor(value: string): string;
4
+ /**
5
+ * Whether two author strings can name the same person. Written surname-position
6
+ * free, because citation styles disagree about it (`Smith, Jane A.`,
7
+ * `Jane A. Smith`, `Smith JA` all reach us) and normalization has already
8
+ * discarded the comma that would settle it.
9
+ *
10
+ * Sharing a name part is necessary but never sufficient: two spelled-out names
11
+ * that each carry a part the other lacks are different people, and where both
12
+ * sides supply given-name evidence it has to agree. Surname-only agreement is
13
+ * accepted only when one side supplies no given name at all — the case where
14
+ * there is nothing to contradict.
15
+ */
16
+ export declare function authorNamesMatch(left: string, right: string): boolean;
17
+ export declare function extractDoi(value: string): string | undefined;
18
+ export declare function extractPmid(value: string): string | undefined;
19
+ export declare function extractArxivId(value: string): string | undefined;
20
+ export declare function normalizeCitation(unparsed: CitationInput): NormalizedCitation;
21
+ /**
22
+ * Identity of the *lookups* an input provokes, not of the input itself.
23
+ *
24
+ * Two citations share this key when the authorities would be asked the same
25
+ * question, so they share one round of source outcomes; each caller's own
26
+ * supplied metadata is compared against those outcomes separately. An exact
27
+ * identifier is the whole question — every operation an identifier plan selects
28
+ * is driven by the identifier alone — so supplied metadata stays out of the key
29
+ * there. A free-text citation is dispatched from its metadata, and folds it in.
30
+ */
31
+ export declare function citationLookupKey(input: CitationInput): string;
@@ -0,0 +1,187 @@
1
+ import { CitationInputSchema } from "./types.js";
2
+ // Each registry defines its own identifier syntax, and each is matched here rather than parsed:
3
+ // a citation string is prose, so the identifier has to be found inside it, not read off a field.
4
+ // The DOI suffix is deliberately permissive — the registry allows nearly any character in it, so
5
+ // there is no delimiter to stop at, and `trimDoiPunctuation` walks back the sentence punctuation
6
+ // and brackets a greedy match swallows. PMID and arXiv anchor instead, having fixed shapes.
7
+ const DOI_PATTERN = /(?:https?:\/\/(?:dx\.)?doi\.org\/|doi\s*:\s*)?(10\.\d{4,9}\/[^\s<>"']+)/i;
8
+ const PMID_PATTERN = /^(?:pmid\s*:\s*)?(\d{1,9})$/i;
9
+ const ARXIV_PATTERN = /^(?:https?:\/\/arxiv\.org\/(?:abs|pdf)\/|arxiv\s*:\s*)?((?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[a-z-]+)?\/\d{7})(?:v\d+)?)(?:\.pdf)?$/i;
10
+ function collapseWhitespace(value) {
11
+ return value.normalize("NFKC").replace(/\s+/g, " ").trim();
12
+ }
13
+ export function normalizeComparable(value) {
14
+ return collapseWhitespace(value)
15
+ .toLocaleLowerCase("en-US")
16
+ .replace(/[\p{P}\p{S}]+/gu, " ")
17
+ .replace(/\s+/g, " ")
18
+ .trim();
19
+ }
20
+ export function normalizeAuthor(value) {
21
+ return normalizeComparable(value)
22
+ .replace(/\b(?:jr|sr|ii|iii|iv)\b/g, "")
23
+ .replace(/\s+/g, " ")
24
+ .trim();
25
+ }
26
+ function authorEvidence(value) {
27
+ const tokens = normalizeAuthor(value).split(" ").filter(Boolean);
28
+ return { words: tokens.filter((token) => token.length > 1), initials: tokens.filter((token) => token.length === 1) };
29
+ }
30
+ /**
31
+ * A token long enough to be a spelled-out name part rather than an unpunctuated
32
+ * block of initials. Two letters is the boundary: `Smith JA` and `Wu` are both
33
+ * live readings of the same shape, so a token that short is never treated as a
34
+ * name part that could contradict one.
35
+ */
36
+ const SPELLED_OUT_MINIMUM = 3;
37
+ /**
38
+ * The given-name letters a name can account for. A short leftover may itself be
39
+ * an initial block, so it contributes every letter; a spelled-out one
40
+ * contributes only the initial it would abbreviate to.
41
+ */
42
+ function givenLetters(leftover, initials) {
43
+ return [...initials, ...leftover.flatMap((word) => (word.length < SPELLED_OUT_MINIMUM ? [...word] : [word[0]]))].sort();
44
+ }
45
+ function containsLetters(available, required) {
46
+ const pool = [...available];
47
+ return required.every((letter) => {
48
+ const index = pool.indexOf(letter);
49
+ if (index < 0)
50
+ return false;
51
+ pool.splice(index, 1);
52
+ return true;
53
+ });
54
+ }
55
+ /**
56
+ * Whether two author strings can name the same person. Written surname-position
57
+ * free, because citation styles disagree about it (`Smith, Jane A.`,
58
+ * `Jane A. Smith`, `Smith JA` all reach us) and normalization has already
59
+ * discarded the comma that would settle it.
60
+ *
61
+ * Sharing a name part is necessary but never sufficient: two spelled-out names
62
+ * that each carry a part the other lacks are different people, and where both
63
+ * sides supply given-name evidence it has to agree. Surname-only agreement is
64
+ * accepted only when one side supplies no given name at all — the case where
65
+ * there is nothing to contradict.
66
+ */
67
+ export function authorNamesMatch(left, right) {
68
+ const a = authorEvidence(left);
69
+ const b = authorEvidence(right);
70
+ if (!a.words.some((word) => b.words.includes(word)))
71
+ return false;
72
+ const leftoverA = a.words.filter((word) => !b.words.includes(word));
73
+ const leftoverB = b.words.filter((word) => !a.words.includes(word));
74
+ const spelledOut = (leftover) => leftover.some((word) => word.length >= SPELLED_OUT_MINIMUM);
75
+ if (spelledOut(leftoverA) && spelledOut(leftoverB))
76
+ return false;
77
+ const lettersA = givenLetters(leftoverA, a.initials);
78
+ const lettersB = givenLetters(leftoverB, b.initials);
79
+ if (lettersA.length === 0 || lettersB.length === 0)
80
+ return true;
81
+ return lettersA.length <= lettersB.length ? containsLetters(lettersB, lettersA) : containsLetters(lettersA, lettersB);
82
+ }
83
+ function trimDoiPunctuation(value) {
84
+ let result = value.replace(/[.,;]+$/g, "");
85
+ const pairs = [
86
+ ["(", ")"],
87
+ ["[", "]"],
88
+ ["{", "}"],
89
+ ];
90
+ for (const [open, close] of pairs) {
91
+ while (result.endsWith(close) && result.split(open).length <= result.split(close).length)
92
+ result = result.slice(0, -1);
93
+ }
94
+ return result;
95
+ }
96
+ export function extractDoi(value) {
97
+ const match = collapseWhitespace(value).match(DOI_PATTERN);
98
+ if (!match?.[1])
99
+ return undefined;
100
+ return trimDoiPunctuation(match[1]).toLocaleLowerCase("en-US");
101
+ }
102
+ export function extractPmid(value) {
103
+ return collapseWhitespace(value).match(PMID_PATTERN)?.[1];
104
+ }
105
+ export function extractArxivId(value) {
106
+ const match = collapseWhitespace(value).match(ARXIV_PATTERN)?.[1];
107
+ return match?.toLocaleLowerCase("en-US").replace(/v\d+$/i, "");
108
+ }
109
+ function classifyUnsupportedWork(value) {
110
+ const normalized = normalizeComparable(value);
111
+ if (/\bpersonal communication\b/.test(normalized))
112
+ return "personal_communication";
113
+ if (/\bunpublished (?:data|work|manuscript)\b/.test(normalized))
114
+ return "unpublished";
115
+ if (/\b(?:in press|forthcoming)\b/.test(normalized))
116
+ return "unregistered_in_press";
117
+ return undefined;
118
+ }
119
+ function normalizedSupplied(input) {
120
+ return {
121
+ ...(input.title === undefined ? {} : { title: normalizeComparable(input.title) }),
122
+ ...(input.authors === undefined ? {} : { authors: input.authors.map(normalizeAuthor).filter(Boolean) }),
123
+ ...(input.year === undefined ? {} : { year: input.year }),
124
+ ...(input.venue === undefined ? {} : { venue: normalizeComparable(input.venue) }),
125
+ ...(input.volume === undefined ? {} : { volume: normalizeComparable(input.volume) }),
126
+ ...(input.firstPage === undefined ? {} : { firstPage: normalizeComparable(input.firstPage) }),
127
+ };
128
+ }
129
+ export function normalizeCitation(unparsed) {
130
+ const input = CitationInputSchema.parse(unparsed);
131
+ const citation = collapseWhitespace(input.citation);
132
+ const doi = extractDoi(citation);
133
+ const pmid = extractPmid(citation);
134
+ const arxiv = extractArxivId(citation);
135
+ const hint = input.kind ?? "auto";
136
+ let kind;
137
+ if (hint === "auto")
138
+ kind = doi ? "doi" : pmid ? "pmid" : arxiv ? "arxiv" : "free_text";
139
+ else
140
+ kind = hint;
141
+ if (kind === "doi" && doi === undefined)
142
+ throw new Error("citation kind is doi but no valid DOI was found");
143
+ if (kind === "pmid" && pmid === undefined)
144
+ throw new Error("citation kind is pmid but no valid PMID was found");
145
+ if (kind === "arxiv" && arxiv === undefined)
146
+ throw new Error("citation kind is arxiv but no valid arXiv id was found");
147
+ const unsupportedWorkKind = kind === "free_text" ? classifyUnsupportedWork(citation) : undefined;
148
+ // A pinned `free_text` kind asserts the citation carries no usable identifier — `auto`
149
+ // would have classified it otherwise. Publishing one anyway splits the pipeline against
150
+ // itself: the plan gives the citation bibliographic-only treatment while candidate
151
+ // scoring short-circuits to a perfect match on an identifier nothing verified.
152
+ const identified = kind !== "free_text";
153
+ return {
154
+ citation,
155
+ query: normalizeComparable(citation),
156
+ kind,
157
+ identifiers: {
158
+ ...(doi === undefined || !identified ? {} : { doi }),
159
+ ...(pmid === undefined || !identified ? {} : { pmid }),
160
+ ...(arxiv === undefined || !identified ? {} : { arxiv }),
161
+ },
162
+ supplied: normalizedSupplied(input),
163
+ ...(unsupportedWorkKind === undefined ? {} : { unsupportedWorkKind }),
164
+ };
165
+ }
166
+ /**
167
+ * Identity of the *lookups* an input provokes, not of the input itself.
168
+ *
169
+ * Two citations share this key when the authorities would be asked the same
170
+ * question, so they share one round of source outcomes; each caller's own
171
+ * supplied metadata is compared against those outcomes separately. An exact
172
+ * identifier is the whole question — every operation an identifier plan selects
173
+ * is driven by the identifier alone — so supplied metadata stays out of the key
174
+ * there. A free-text citation is dispatched from its metadata, and folds it in.
175
+ */
176
+ export function citationLookupKey(input) {
177
+ const normalized = normalizeCitation(input);
178
+ return JSON.stringify(normalized.kind === "free_text"
179
+ ? {
180
+ kind: normalized.kind,
181
+ identifiers: normalized.identifiers,
182
+ query: normalized.query,
183
+ supplied: normalized.supplied,
184
+ unsupportedWorkKind: normalized.unsupportedWorkKind ?? null,
185
+ }
186
+ : { kind: normalized.kind, identifiers: normalized.identifiers });
187
+ }
@@ -0,0 +1,2 @@
1
+ import type { CitationInput, CitationSourcePlanItem, NormalizedCitation } from "./types.js";
2
+ export declare function planCitationSources(input: CitationInput, normalized: NormalizedCitation): CitationSourcePlanItem[];