Skip to content

Citation sources

Reading the source a citation check quotes, and deciding whether it is tied to the reference.

judge_citation reads the source itself: it never takes text the agent pastes. What it read is kept as an observation, a JSON record logged with the call: which file or URL, the digest of the bytes, the digest of their normalised text, where the quoted passage is in that text, and what ties the source to the reference. The bytes themselves are not logged. The decision the check rests on (tie) is made from the observation, the reference's bibliography entry and the digests start_run pinned, so the run can be rebuilt from its log without reading anything again; replay reads the bytes again (verify_observation) and refuses an observation they do not give.

A source is tied to the reference when it is one of:

  1. the file named by the entry's key in the run's copy of the cited works, cited/, while its bytes still have the digest start_run recorded;
  2. the arXiv paper the entry names, fetched by the tool from the identifier in the entry (never the agent's), at the version the entry names or, when it names none, the current one, whose version is recorded;
  3. the work the entry's DOI resolves to: the agent's URL, after redirects, ends at the URL the tool reached by requesting https://doi.org/<the entry's DOI>, or the agent gave that DOI URL itself.

Any other source is read, and its passage located, but its check is an error: it moves no credence.

SourceError

Bases: Exception

The source cannot be read.

Attributes:

Name Type Description
code

refused-url for a URL the fetch policy refuses, unreadable-source otherwise.

Source code in src/credencegraph_extract/citation.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class SourceError(Exception):
    """The source cannot be read.

    Attributes:
        code: ``refused-url`` for a URL the fetch policy refuses, ``unreadable-source`` otherwise.
    """

    def __init__(self, code: str, message: str) -> None:
        """Make the error.

        Args:
            code: ``refused-url`` or ``unreadable-source``.
            message: Why, in words.
        """
        super().__init__(message)
        self.code = code

__init__(code, message)

Make the error.

Parameters:

Name Type Description Default
code str

refused-url or unreadable-source.

required
message str

Why, in words.

required
Source code in src/credencegraph_extract/citation.py
76
77
78
79
80
81
82
83
84
def __init__(self, code: str, message: str) -> None:
    """Make the error.

    Args:
        code: ``refused-url`` or ``unreadable-source``.
        message: Why, in words.
    """
    super().__init__(message)
    self.code = code

arxiv_id(reference)

Return the arXiv identifier a bibliography entry names, if it names one.

Parameters:

Name Type Description Default
reference Node

The reference node.

required

Returns:

Type Description
str | None

The first identifier in its statement, or None.

Source code in src/credencegraph_extract/citation.py
87
88
89
90
91
92
93
94
95
96
97
def arxiv_id(reference: Node) -> str | None:
    """Return the arXiv identifier a bibliography entry names, if it names one.

    Args:
        reference: The reference node.

    Returns:
        The first identifier in its statement, or ``None``.
    """
    found = _ARXIV.search(reference.statement or "")
    return found[1] if found else None

entry_doi(reference)

Return the DOI a bibliography entry names, in lower case, if it names one.

Parameters:

Name Type Description Default
reference Node

The reference node.

required

Returns:

Type Description
str | None

The first DOI in its statement, without trailing punctuation, or None.

Source code in src/credencegraph_extract/citation.py
100
101
102
103
104
105
106
107
108
109
110
def entry_doi(reference: Node) -> str | None:
    """Return the DOI a bibliography entry names, in lower case, if it names one.

    Args:
        reference: The reference node.

    Returns:
        The first DOI in its statement, without trailing punctuation, or ``None``.
    """
    found = _DOI.search(reference.statement or "")
    return found[1].rstrip(".,;:)]").lower() if found else None

key_file(reference, names)

Return the file of the cited works that a reference's key names: the first of its suffixes there.

Parameters:

Name Type Description Default
reference Node

The reference node.

required
names Mapping[str, str] | set[str]

The names of the files of the run's cited works.

required

Returns:

Type Description
str | None

<key>.txt, <key>.tex or <key>.pdf, the first there, or None.

Source code in src/credencegraph_extract/citation.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def key_file(reference: Node, names: Mapping[str, str] | set[str]) -> str | None:
    """Return the file of the cited works that a reference's key names: the first of its suffixes there.

    Args:
        reference: The reference node.
        names: The names of the files of the run's cited works.

    Returns:
        ``<key>.txt``, ``<key>.tex`` or ``<key>.pdf``, the first there, or ``None``.
    """
    key = reference.attributes.get("key")
    if not isinstance(key, str) or not _KEY.fullmatch(key):
        return None
    return next((f"{key}{suffix}" for suffix in SUFFIXES if f"{key}{suffix}" in names), None)

read_cited(run_dir, source, passage)

Read a file of the run's copy of the cited works.

Parameters:

Name Type Description Default
run_dir Path

The run directory.

required
source str

The file's name, or a path to it in <run>/cited/.

required
passage str

The passage the agent quotes.

required

Returns:

Type Description
tuple[dict[str, Any], bytes]

The observation (kind cited, file, digest, text_digest, found) and the bytes.

Raises:

Type Description
SourceError

If the path is not a regular file directly in the run's cited/, or cannot be read.

Source code in src/credencegraph_extract/citation.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def read_cited(run_dir: Path, source: str, passage: str) -> tuple[dict[str, Any], bytes]:
    """Read a file of the run's copy of the cited works.

    Args:
        run_dir: The run directory.
        source: The file's name, or a path to it in ``<run>/cited/``.
        passage: The passage the agent quotes.

    Returns:
        The observation (``kind`` ``cited``, ``file``, ``digest``, ``text_digest``, ``found``) and the bytes.

    Raises:
        SourceError: If the path is not a regular file directly in the run's ``cited/``, or cannot be read.
    """
    cited = run_dir / CITED
    path = Path(source)
    name = path.name
    inside = path == Path(name) or (path.is_absolute() and path.parent.resolve() == cited.resolve())
    target = cited / name
    if not inside or not name or target.is_symlink() or not target.is_file():
        msg = f"{source!r} is not a file of the run's copy of the cited works; a local source is read only from there"
        raise SourceError("unreadable-source", msg)
    data = target.read_bytes()
    observation = {"kind": "cited", "file": name, "digest": bytes_digest(data)}
    return _located(observation, text_of(data, target.suffix), passage), data

read_url(reference, source, passage, fetcher=fetch)

Fetch a URL source, and what ties it to the reference.

An arXiv URL of the identifier the entry names is read from arXiv at the entry's version, or the current one; a doi.org URL of the entry's DOI is read through doi.org. Any other URL is read as given, and, when the entry names a DOI, the DOI is resolved too, so the two final URLs can be compared.

Parameters:

Name Type Description Default
reference Node

The reference node.

required
source str

The URL the agent gave.

required
passage str

The passage the agent quotes.

required
fetcher Fetch

Fetches a URL under the fetch policy.

fetch

Returns:

Type Description
dict[str, Any]

The observation (kind url, the fetch's URLs and content type, digest, tie,

bytes

text_digest, found) and the bytes.

Raises:

Type Description
SourceError

If a URL is refused, cannot be fetched, or the body cannot be read.

Source code in src/credencegraph_extract/citation.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def read_url(reference: Node, source: str, passage: str, fetcher: Fetch = fetch) -> tuple[dict[str, Any], bytes]:
    """Fetch a URL source, and what ties it to the reference.

    An arXiv URL of the identifier the entry names is read from arXiv at the entry's version, or the
    current one; a doi.org URL of the entry's DOI is read through doi.org. Any other URL is read as
    given, and, when the entry names a DOI, the DOI is resolved too, so the two final URLs can be compared.

    Args:
        reference: The reference node.
        source: The URL the agent gave.
        passage: The passage the agent quotes.
        fetcher: Fetches a URL under the fetch policy.

    Returns:
        The observation (``kind`` ``url``, the fetch's URLs and content type, ``digest``, ``tie``,
        ``text_digest``, ``found``) and the bytes.

    Raises:
        SourceError: If a URL is refused, cannot be fetched, or the body cannot be read.
    """
    tie: dict[str, Any] | None = None
    entry_arxiv = arxiv_id(reference)
    doi = entry_doi(reference)
    given = _ARXIV_URL.fullmatch(source)
    if entry_arxiv is not None and given is not None and given["id"] == _split_arxiv(entry_arxiv)[0]:
        fetched, tie = _arxiv(entry_arxiv, fetcher)
    elif doi is not None and _doi_of_url(source) == doi:
        fetched = _fetched(DOI_URL.format(doi), fetcher)
        tie = {"by": "doi", "doi": doi, "final_url": fetched.final_url}
    else:
        fetched = _fetched(source, fetcher)
        if doi is not None:
            try:
                resolved = _fetched(DOI_URL.format(doi), fetcher)
            except SourceError:
                tie = None
            else:
                tie = {"by": "doi", "doi": doi, "final_url": resolved.final_url}
    kind = fetched.content_type or ("application/pdf" if fetched.data.startswith(b"%PDF") else "")
    observation = {"kind": "url", **describe(fetched), "digest": bytes_digest(fetched.data), "tie": tie}
    return _located(observation, text_of(fetched.data, kind), passage), fetched.data

text_of(data, kind)

Return the normalised text of a source's bytes.

Parameters:

Name Type Description Default
data bytes

The bytes.

required
kind str

A file suffix (.txt, .tex, .pdf) or a media type (text/plain, text/html, application/xhtml+xml, application/pdf).

required

Returns:

Type Description
str

The text, normalised as a quoted passage is.

Raises:

Type Description
SourceError

If the kind is not one of those, or the bytes cannot be read as it.

Source code in src/credencegraph_extract/citation.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def text_of(data: bytes, kind: str) -> str:
    """Return the normalised text of a source's bytes.

    Args:
        data: The bytes.
        kind: A file suffix (``.txt``, ``.tex``, ``.pdf``) or a media type (``text/plain``,
            ``text/html``, ``application/xhtml+xml``, ``application/pdf``).

    Returns:
        The text, normalised as a quoted passage is.

    Raises:
        SourceError: If the kind is not one of those, or the bytes cannot be read as it.
    """
    try:
        if kind in {".txt", "text/plain"}:
            text = data.decode("utf-8")
        elif kind in {"text/html", "application/xhtml+xml"}:
            parser = _Text()
            parser.feed(data.decode("utf-8", errors="replace"))
            parser.close()
            text = "".join(parser.parts)
        elif kind in {".pdf", "application/pdf", ".tex"}:
            suffix = ".tex" if kind == ".tex" else ".pdf"
            with tempfile.TemporaryDirectory() as directory:
                path = Path(directory) / f"source{suffix}"
                path.write_bytes(data)
                chunks = read_latex(path) if suffix == ".tex" else read_pdf(path)
            text = " ".join(chunk.text for chunk in chunks)
        else:
            raise SourceError("unreadable-source", f"a source of type {kind!r} cannot be read")
    except (UnicodeDecodeError, LatexError, PdfError, OSError) as error:
        raise SourceError("unreadable-source", f"the source cannot be read as {kind}: {type(error).__name__}") from None
    normalised = normalise_text(text)
    if not normalised:
        raise SourceError("unreadable-source", "the source has no text")
    return normalised

tie(reference, pins, observation)

Decide whether an observed source is tied to the reference; return why not, or None when it is.

Parameters:

Name Type Description Default
reference Node

The reference node.

required
pins Mapping[str, str]

The digest of each file of the cited works, as start_run recorded it.

required
observation Mapping[str, Any]

What the tool read.

required

Returns:

Type Description
str | None

None for a tied source, else the reason it is not, in words.

Source code in src/credencegraph_extract/citation.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def tie(reference: Node, pins: Mapping[str, str], observation: Mapping[str, Any]) -> str | None:
    """Decide whether an observed source is tied to the reference; return why not, or ``None`` when it is.

    Args:
        reference: The reference node.
        pins: The digest of each file of the cited works, as ``start_run`` recorded it.
        observation: What the tool read.

    Returns:
        ``None`` for a tied source, else the reason it is not, in words.
    """
    if observation.get("kind") == "cited":
        return _cited_tie(reference, pins, observation)
    found = observation.get("tie")
    if isinstance(found, Mapping) and found.get("by") == "arxiv":
        return _arxiv_tie(reference, found, observation)
    if isinstance(found, Mapping) and found.get("by") == "doi":
        return _doi_tie(reference, found, observation)
    return _NOT_TIED

verify_observation(observation, data, passage)

Return the fields of an observation that the source's bytes do not give again.

Parameters:

Name Type Description Default
observation Mapping[str, Any]

What the tool recorded.

required
data bytes

The bytes, found by their digest.

required
passage str

The passage the agent quoted.

required

Returns:

Type Description
list[str]

The names of the fields that differ: digest, text_digest or found; or unreadable.

Source code in src/credencegraph_extract/citation.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def verify_observation(observation: Mapping[str, Any], data: bytes, passage: str) -> list[str]:
    """Return the fields of an observation that the source's bytes do not give again.

    Args:
        observation: What the tool recorded.
        data: The bytes, found by their digest.
        passage: The passage the agent quoted.

    Returns:
        The names of the fields that differ: ``digest``, ``text_digest`` or ``found``; or ``unreadable``.
    """
    if bytes_digest(data) != observation.get("digest"):
        return ["digest"]
    kind = (
        Path(str(observation.get("file"))).suffix
        if observation.get("kind") == "cited"
        else observation.get("content_type")
    )
    if observation.get("kind") == "url" and not kind:
        kind = "application/pdf" if data.startswith(b"%PDF") else ""
    try:
        again = _located({}, text_of(data, str(kind)), passage)
    except SourceError:
        return ["unreadable"]
    return [name for name in ("text_digest", "found") if again[name] != observation.get(name)]