@inquivent/research-ids 0.0.0-stage → 1.0.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.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @inquivent/research-ids
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DOI, arXiv and OpenAlex parsing and handoff URLs for the Inquivent products. Implements
4
+ [handoff contract v1](../../contracts/handoff-v1/README.md) (V1 spec §6) and passes its test vectors.
5
+ The contract files ship in `contract/`, so consumers test against the version they pin.
6
+
7
+ ```ts
8
+ import { buildHandoffUrl, parseDoi, parseHandoffQuery } from "@inquivent/research-ids";
9
+
10
+ parseDoi("https://doi.org/10.1038/NPHYS1170");
11
+ // { ok: true, value: { doi: "10.1038/NPHYS1170", doi_key: "10.1038/nphys1170" } }
12
+
13
+ buildHandoffUrl("https://axiomizer.inquivent.com", {
14
+ identifiers: { doi: "10.1038/nphys1170" },
15
+ intent: "explain",
16
+ from: "paperinsight",
17
+ });
18
+ // "https://axiomizer.inquivent.com/open?v=1&doi=10.1038%2Fnphys1170&intent=explain&from=paperinsight"
19
+
20
+ parseHandoffQuery("v=1&arxiv=1706.03762v5&intent=explain");
21
+ // { status: "ok", identifiers: { arxiv: {...} }, intent: "explain", from: null, section: null }
22
+ ```
23
+
24
+ | Export | Purpose |
25
+ |---|---|
26
+ | `parseDoi`, `parseArxiv`, `parseOpenAlex`, `parseIdentifier` | Normalize an identifier (contract §1) |
27
+ | `sameWork`, `sameDocument` | Matching rules (contract §1) |
28
+ | `buildHandoffUrl` | The only way to build a handoff link (V1 spec §6.5). Throws `HandoffError` |
29
+ | `parseHandoffQuery` | Read an incoming `/open` or availability query (contract §2.2) |
30
+ | `@inquivent/research-ids/contract/*.json` | The test vectors, `/open` vectors and availability schema |
@@ -0,0 +1,149 @@
1
+ # Research handoff contract v1
2
+
3
+ This is the normative detail behind V1 spec §6. It covers how products parse research identifiers, how
4
+ they link to each other, and what the availability endpoint returns. Two implementations follow it:
5
+
6
+ - `@inquivent/research-ids` in `packages/research-ids/` (TypeScript)
7
+ - `inquivent-research-ids` in `python/research-ids/` (Python)
8
+
9
+ Both MUST pass every case in [`test-vectors.json`](test-vectors.json). If an implementation and this
10
+ document disagree, the vectors win. Change the vectors first, then both libraries, in the same PR.
11
+
12
+ ## 1. Identifiers
13
+
14
+ Inputs are trimmed of surrounding whitespace first. Empty input is error `empty`. Input longer than
15
+ 300 characters after trimming is error `too_long`.
16
+
17
+ ### 1.1 DOI
18
+
19
+ 1. **Percent-decode once.** Every `%XX` sequence is decoded and the bytes are read as UTF-8. A `%` that
20
+ is not followed by two hex digits, or bytes that are not valid UTF-8, give error `bad_encoding`.
21
+ Callers that already decoded the value, such as a query-string parser, skip this step (§2.2).
22
+ 2. **Strip one prefix**, case-insensitively: `https://doi.org/`, `http://doi.org/`,
23
+ `https://dx.doi.org/`, `http://dx.doi.org/`, or `doi:` followed by optional spaces.
24
+ 3. **Validate** against `^10\.[0-9]{4,9}(\.[0-9]+)*/\S+$`. A failure gives `invalid_doi`.
25
+
26
+ Stored fields:
27
+
28
+ | Field | Value |
29
+ |---|---|
30
+ | `doi` | The result of the steps above, in its original case. Use it for display, citation and handoff URLs |
31
+ | `doi_key` | `doi` with ASCII `A`–`Z` lowercased and every other character unchanged. DOI names are case-insensitive for ASCII only (DOI Handbook §2.4) |
32
+
33
+ Same work: equal `doi_key`.
34
+
35
+ ### 1.2 arXiv
36
+
37
+ 1. **Strip one prefix**, case-insensitively: `arxiv:`, or `http(s)://(www.)arxiv.org/abs/`, or
38
+ `http(s)://(www.)arxiv.org/pdf/`. After a `/pdf/` prefix, a trailing `.pdf` is also removed.
39
+ 2. **Match one of the two styles**:
40
+
41
+ | Style | Pattern | Extra rules |
42
+ |---|---|---|
43
+ | New (April 2007 onward) | `YYMM.NNNN` or `YYMM.NNNNN`, then optional `vN` | `MM` is 01–12. Four digits only for 0704–1412, five digits only from 1501 |
44
+ | Old (1991 to March 2007) | `archive(.SC)/YYMMNNN`, then optional `vN` | `archive` is letters with an optional hyphen (`hep-th`, `math`). `SC` is a two-letter subject class. `YYMM` is 9108–9912 or 0001–0703 |
45
+
46
+ `N` in `vN` is an integer of at least 1 with no leading zero. Anything else gives `invalid_arxiv`.
47
+
48
+ Stored fields:
49
+
50
+ | Field | Value |
51
+ |---|---|
52
+ | `arxiv_base` | New style: `YYMM.NNNNN`. Old style: the archive in lowercase, `/`, then `YYMMNNN`. **The subject class is dropped.** arXiv does not treat it as part of the identifier, so `math.GT/0309136` and `math/0309136` are the same paper |
53
+ | `arxiv_version` | The integer from `vN`, or `null` when no version was given |
54
+
55
+ - **Same work:** equal `arxiv_base`.
56
+ - **Same document:** equal `arxiv_base` and both versions given and equal. A missing version means
57
+ "whichever is latest", which can change, so it never matches as the same document.
58
+ - **Never drop a given version (§6.2).** Format a stored value back with `arxiv_base` plus `v<version>`
59
+ when the version is not null.
60
+
61
+ ### 1.3 OpenAlex
62
+
63
+ 1. **Strip one prefix**, case-insensitively: `https://openalex.org/` or `http://openalex.org/`.
64
+ 2. **Validate** against `^[Ww][1-9][0-9]{0,14}$`. Only work IDs (`W…`) are accepted. Author, venue and
65
+ API URLs give `invalid_openalex`.
66
+
67
+ Stored field: `openalex_id`, with the `W` uppercased. Same work: equal `openalex_id`.
68
+
69
+ ### 1.4 Error codes
70
+
71
+ `empty`, `too_long`, `bad_encoding`, `invalid_doi`, `invalid_arxiv`, `invalid_openalex`.
72
+
73
+ ## 2. Handoff URL
74
+
75
+ ```
76
+ https://<product-host>/open?v=1[&doi=…][&arxiv=…][&openalex=…]&intent=<intent>&from=<from>[&section=<slug>]
77
+ ```
78
+
79
+ ### 2.1 Building (`buildHandoffUrl` / `build_handoff_url`)
80
+
81
+ The builder takes the base URL and raw identifier strings, and parses each one with §1. An invalid
82
+ identifier is an error, never silently dropped. It needs at least one identifier (`missing_identifier`).
83
+
84
+ | Rule | Value |
85
+ |---|---|
86
+ | `intent` | One of `explain`, `reproduce`, `summarize`, `view`. Anything else gives `invalid_intent` |
87
+ | `from` | One of `paperinsight`, `axiomizer`, `dataxiom`, `inquivent`. Anything else gives `invalid_from` |
88
+ | `section` | Optional slug matching `^[a-z0-9]+(-[a-z0-9]+)*$`, at most 64 characters. Anything else gives `invalid_section` |
89
+ | Parameter order | `v`, `doi`, `arxiv`, `openalex`, `intent`, `from`, `section` |
90
+ | Values sent | `doi`: the stored `doi`. `arxiv`: `arxiv_base` plus `v<version>` when known. `openalex`: `openalex_id` |
91
+ | Encoding | Every byte outside `A–Z a–z 0–9 - . _ ~` is percent-encoded as UTF-8 with uppercase hex, and a space becomes `%20`. This equals JavaScript `encodeURIComponent` with `!'()*` also escaped, and Python `urllib.parse.quote(value, safe="")` |
92
+ | Base URL | A trailing `/` is removed before `/open` is appended |
93
+
94
+ When a work has both a DOI and an arXiv ID, send both (V1-I01).
95
+
96
+ ### 2.2 Receiving (`parseHandoffQuery` / `parse_handoff_query`)
97
+
98
+ The query string is decoded with standard form decoding: `+` becomes a space and `%XX` is decoded.
99
+ Identifier values are then parsed with §1 **without** the DOI percent-decoding step, so a value is
100
+ never decoded twice. Repeated parameters use the first occurrence. An empty value counts as absent.
101
+
102
+ | Condition | Result |
103
+ |---|---|
104
+ | `v` missing | `invalid`, error `missing_version` |
105
+ | `v` present but not `1` | `invalid`, error `unsupported_version` |
106
+ | No `doi`, `arxiv` or `openalex` | `invalid`, error `missing_identifier` |
107
+ | Any identifier present but invalid | `invalid`, error `invalid_identifier` |
108
+ | Otherwise | `ok` |
109
+
110
+ When the result is `ok`, the other fields are resolved like this:
111
+
112
+ | Field | Missing or unknown value | Invalid value |
113
+ |---|---|---|
114
+ | `intent` | `view` | n/a |
115
+ | `from` | `null` | n/a |
116
+ | `section` | `null` | `null`, never an error |
117
+
118
+ ### 2.3 Product responses (V1 spec §6.3)
119
+
120
+ | Parse result | Response | `landing` event `outcome` |
121
+ |---|---|---|
122
+ | `ok`, and the product has content for the intent | 302 to the content | `matched` |
123
+ | `ok`, no content | 200 fallback page | `fallback` |
124
+ | `invalid` | 400 page listing the accepted formats | `invalid` |
125
+
126
+ A well-formed request never returns 404 or 500. [`open-vectors.json`](open-vectors.json) lists the
127
+ requests the ecosystem suite sends to each product.
128
+
129
+ ## 3. Availability endpoint
130
+
131
+ `GET /api/handoff/v1/availability?doi=…` (or `arxiv=` or `openalex=`), validated by
132
+ [`availability.schema.json`](availability.schema.json).
133
+
134
+ | Case | Status and body |
135
+ |---|---|
136
+ | Content exists | 200 `{"v": 1, "available": true, "kind": "<kind>", "title": "…", "url": "https://…"}` |
137
+ | No content | 200 `{"v": 1, "available": false}` |
138
+ | No identifier or an invalid one | 400 `{"v": 1, "available": false, "error": "<code>"}`, with codes from §2.2 |
139
+ | Over 60 requests per minute per IP | 429 with `Retry-After` |
140
+
141
+ `kind` is `explanation` (Axiomizer), `study` (Dataxiom) or `summary` (PaperInsight).
142
+
143
+ The endpoint returns these headers:
144
+
145
+ - `Cache-Control: public, max-age=600`
146
+ - `Access-Control-Allow-Origin`, echoing the request origin when it matches `https://*.inquivent.com`
147
+ - `Vary: Origin`
148
+
149
+ Callers query it server-side with an 800 ms timeout, and hide the button on any error.
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://inquivent.com/schemas/handoff-v1/availability.schema.json",
4
+ "title": "Handoff availability response (contract v1, README §3)",
5
+ "type": "object",
6
+ "required": ["v", "available"],
7
+ "properties": {
8
+ "v": { "const": 1 },
9
+ "available": { "type": "boolean" },
10
+ "kind": { "enum": ["explanation", "study", "summary"] },
11
+ "title": { "type": "string", "minLength": 1, "maxLength": 300 },
12
+ "url": { "type": "string", "format": "uri", "pattern": "^https://" },
13
+ "error": {
14
+ "enum": ["missing_identifier", "invalid_identifier"]
15
+ }
16
+ },
17
+ "additionalProperties": false,
18
+ "if": { "properties": { "available": { "const": true } } },
19
+ "then": { "required": ["kind", "title", "url"], "not": { "required": ["error"] } },
20
+ "else": { "not": { "anyOf": [{ "required": ["kind"] }, { "required": ["title"] }, { "required": ["url"] }] } }
21
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "contract": "inquivent-handoff",
3
+ "version": 1,
4
+ "description": "Requests the ecosystem suite sends to each product's /open endpoint (README \u00a72.3). 'matched' cases name content each product seeds (Axiomizer seed_demo, Dataxiom seed_heart_disease_study); update them when the seed data changes. PaperInsight stores only user-submitted papers, so it has no fixed matched case.",
5
+ "common": [
6
+ {
7
+ "id": "open-invalid-001",
8
+ "query": "doi=10.1000/182&intent=view&from=inquivent",
9
+ "status": 400,
10
+ "outcome": "invalid"
11
+ },
12
+ {
13
+ "id": "open-invalid-002",
14
+ "query": "v=1&doi=11.1/x&from=inquivent",
15
+ "status": 400,
16
+ "outcome": "invalid"
17
+ },
18
+ {
19
+ "id": "open-invalid-003",
20
+ "query": "v=1&intent=view",
21
+ "status": 400,
22
+ "outcome": "invalid"
23
+ },
24
+ {
25
+ "id": "open-fallback-001",
26
+ "query": "v=1&doi=10.5555%2Finquivent-handoff-fallback-test&intent=view&from=inquivent",
27
+ "status": 200,
28
+ "outcome": "fallback"
29
+ },
30
+ {
31
+ "id": "open-fallback-002",
32
+ "query": "v=1&arxiv=0704.0001v1&intent=dance&from=inquivent",
33
+ "status": 200,
34
+ "outcome": "fallback"
35
+ }
36
+ ],
37
+ "matched": {
38
+ "axiomizer": [
39
+ {
40
+ "id": "open-matched-axiomizer-001",
41
+ "query": "v=1&doi=10.1037%2Fh0055392&intent=explain&from=inquivent",
42
+ "status": 302,
43
+ "outcome": "matched",
44
+ "location_path": "/papers/fitts-law/"
45
+ },
46
+ {
47
+ "id": "open-matched-axiomizer-002",
48
+ "query": "v=1&doi=https%3A%2F%2Fdoi.org%2F10.1037%2FH0055392&intent=view&from=paperinsight",
49
+ "status": 302,
50
+ "outcome": "matched",
51
+ "location_path": "/papers/fitts-law/"
52
+ }
53
+ ],
54
+ "dataxiom": [
55
+ {
56
+ "id": "open-matched-dataxiom-001",
57
+ "query": "v=1&doi=10.1016%2F0002-9149%2889%2990524-9&intent=reproduce&from=axiomizer",
58
+ "status": 302,
59
+ "outcome": "matched",
60
+ "location_path": "/studies/heart-disease-risk-screening/"
61
+ },
62
+ {
63
+ "id": "open-matched-dataxiom-002",
64
+ "query": "v=1&doi=10.24432%2FC52P4X&intent=view&from=inquivent",
65
+ "status": 302,
66
+ "outcome": "matched",
67
+ "location_path": "/studies/heart-disease-risk-screening/"
68
+ }
69
+ ],
70
+ "paperinsight": []
71
+ }
72
+ }