Skip to content

fix(resolve): don't resolve Id. to a cite inside a parenthetical - #360

Open
zoeb-nomi wants to merge 2 commits into
freelawproject:mainfrom
zoeb-nomi:fix/id-resolution-348
Open

zoeb-nomi wants to merge 2 commits into
freelawproject:mainfrom
zoeb-nomi:fix/id-resolution-348

Conversation

@zoeb-nomi

@zoeb-nomi zoeb-nomi commented Oct 19, 2026 •

Copy link
Copy Markdown

Fixes

Fixes: #348

Summary

This PR stops resolve_citations() from treating a citation inside an explanatory parenthetical as the antecedent of a later Id.. With the Sidhu passage from the issue, both Id.s now resolve to Holley, 379 F. Supp. 3d 809. Before, the first was dropped (the parenthetical's Levine short cite didn't resolve) and the second went to Gibbons.

Approach. A new helper, _in_parenthetical(), makes one pass over each citation's document.plain_text and keeps a stack of open parens. A citation counts as nested only if one of the parens around it is an explanatory parenthetical: it directly follows a citation (inside that citation's full span, or separated from it only by a pin cite and complete parentheticals) and opens with a lowercase present participle (citing, quoting, holding, explaining, …; EXPLANATORY_PARENTHETICAL_REGEX, which excludes prepositions such as during, including, according, regarding, concerning, notwithstanding). An ordinary prose aside like (according to Foo, 1 U.S. 1) is not nested, so a following Id. still resolves to Foo. resolve_citations() then keeps two antecedents:

  • an Id. outside a parenthetical resolves against the last citation that was also outside one;
  • an Id. inside a parenthetical keeps the current behavior and resolves against the immediately preceding citation.

Nested citations are still resolved to their own resources. They just don't become the antecedent for the next unnested Id.. The resolve_id_citation callback signature is unchanged.

Gotchas / compromises

  • The participle test means parenthesized citation sentences like (See Smith, 1 U.S. 1.) Id. are not treated as nested, so Id. still refers to Smith. Explanatory parentheticals that don't start with an -ing word, e.g. (per curiam) (Smith v. Jones ...), aren't detected and fall back to the current behavior.
  • Unbalanced text degrades to the current behavior, never worse: stray ) are ignored, and an unclosed ( only affects the citations it actually precedes. Citations without a document, such as hand-built ones, are treated as unnested.
  • Separate bug, left out of scope: in Id. at 827 (quoting …), the pin cite isn't captured, because (quoting is a StopWordToken and extract_pin_cite stops before it. The Id. still resolves correctly here, because a missing pin cite skips the page-range check. Reported separately in Pin cite and parenthetical are dropped when a short-form cite is followed by (quoting …), (citing …), or another stop word #361.

Testing

  • Review follow-ups in 5371201: explanatory parens must directly follow a citation and prepositions are excluded; resolve_citations() materialises its input once so generators work; get_citations() now sets document on reference citations. Each has a regression test.
  • New test_id_resolution_skips_parentheticals uses the issue's passage plus nested parentheticals, a parenthetical closing before a new antecedent, an id. inside a parenthetical, a parenthesized citation sentence, and an unclosed paren. It fails on main with exactly the issue's symptom.
  • python -m unittest discover -s tests -p 'test_*.py': 56 tests, OK. ruff 0.11.8 check and format, and mypy ., are clean.
  • Benchmark job run locally as CI runs it (one-percent corpus, PR vs main): 0 gains, 0 losses, 15,988 citations on both sides. That's expected, since the benchmark measures extraction, not resolution.
  • To cover resolution, I diffed Id. resolutions on the same corpus (130 opinions, 595 Id. cites). 37 changed:
    • 25 now point to the correct antecedent. 19 moved from the parenthetical's citation to the host citation (e.g. Kwai Fun Wong, Lorazepam, Hawkins v. State). 6 that were previously unresolved now resolve correctly (e.g. Intrepid v. Pollock, Johnson v. Jones).
    • 10 that were previously resolved to the wrong case are now unresolved. All are in one Mathis discussion, where the host is a short cite that doesn't resolve in that document.
    • 2 are debatable: an Id. after see 18 U.S.C. § 3583(e) (2012) (specifying …) now resolves to § 3583. That is the immediately preceding authority, though probably not what the author meant.

AI Disclosure

  • No AI tools were used to create the content of this PR.
  • Parts of this PR were created with the help of an AI tool, and I have carefully reviewed all of its content and take full responsibility for it.

An Id. was resolved to the most recently resolved citation, even when that
citation sat inside an explanatory parenthetical such as "(citing X)" or
"(quoting Y)". It now refers back to the citation the parenthetical belongs
to, and is no longer dropped when the parenthetical's citation can't be
resolved.

A citation counts as nested only inside a paren that opens with a lowercase
present participle (citing, quoting, holding, ...), so parenthesized citation
sentences like "(See 1 U.S. 1.)" keep the old behavior, as does an Id. that
is itself inside a parenthetical.

Fixes freelawproject#348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CLAassistant

CLAassistant commented Oct 19, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@mlissner
mlissner requested a review from grossir October 19, 2026 18:42
@grossir grossir added the run-benchmark Runs benchmark in fork PRs label Oct 20, 2026
@grossir

grossir commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@claude review

@grossir grossir moved this to Needs triage in Sprint (Case Law) Oct 20, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread eyecite/resolve.py Outdated
# Explanatory parentheticals open with a lowercase present participle, as in
# "(citing ...)", "(quoting ...)", or "(holding that ...)". This tells them
# apart from parenthesized citation sentences like "(See 1 U.S. 1.)":
EXPLANATORY_PARENTHETICAL_REGEX = re.compile(r"\s*[a-z]+ing\b")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 An Id. right after an ordinary prose aside such as "(according to Foo, 1 U.S. 1)" now skips Foo, so it attaches to an older cite or is dropped. EXPLANATORY_PARENTHETICAL_REGEX \s*[a-z]+ing\b matches any lowercase word ending in -ing. That includes prepositions: during, including, according, regarding, concerning, notwithstanding. The match counts anywhere in plain_text, not only on parens that follow a citation. Fix: treat a paren as explanatory only when it directly follows a citation, e.g. it falls inside the preceding citation's metadata.parenthetical / full_span. Also exclude these prepositions.

Why this was flagged

_in_parenthetical (resolve.py:262-266) runs the regex on every "(" in document.plain_text. It does not check whether that paren belongs to a citation. Take "Smith v. Jones, 5 U.S. 5. Courts agree (according to Foo v. Bar, 1 U.S. 1, 3). Id. at 4." The regex matches " according": [a-z]+ backtracks to "accord" + "ing" and \b holds before the space. So Foo is flagged nested and leaves last_unnested_resolution unchanged. The Id. is outside any paren and takes last_unnested_resolution (resolve.py:403), so it attaches to Smith. On the base branch it attaches to Foo, the citation right before it. When no earlier citation sits outside a paren, last_unnested_resolution is None and the Id. is dropped (resolve.py:281-282). The same happens for "(including X, ...)", "(during ...)", "(regarding ...)", "(concerning ...)" and "(notwithstanding ...)". Nothing guards this, because the regex is applied to text-level parens with no reference to citation metadata.

Verification: EXPLANATORY_PARENTHETICAL_REGEX at resolve.py:32 is applied at m.end() of every "(" in document.plain_text. Nothing checks that the paren is attached to a citation, and nothing checks that the word is a participle. The later "Id. at 4." gets last_unnested_resolution, so it resolves to Smith instead of Foo. On the base, last_resolution was used, so this Id. resolved to Foo.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5371201. A paren now counts as an explanatory parenthetical only when it directly follows a citation: it either falls inside the preceding citation's full span, or only a pin cite and complete parentheticals come between them (e.g. Id. at 827 (quoting …)). The participle check also now excludes during/including/according/regarding/concerning/notwithstanding. Your example resolves the Id. to Foo again, as on main. New regression tests cover the prose aside, a (citing …) aside that doesn't follow a citation, and (including …) right after a citation. On the benchmark corpus, the set of Id. resolutions that change vs main is identical to before (37), so the tighter rule didn't drop any of the corrections.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5371201: resolve_citations() now does citations = list(citations) up front, and there's a new test that passes a generator.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5371201 by setting reference.document = document where get_citations() collects references (two lines in find.py). That covers markup references too, and removes the pos = 0 rescan. One note: in the exact example given, Smith's full span already covers (quoting Foo at 3), so filter_citations() drops the overlapping reference and main already resolves the Id. to Smith. The bug does show when the reference survives, e.g. Smith v. Jones, 2 U.S. 2, 5 (quoting Foo at 3). Id. at 4., which is now a regression test.

Comment thread eyecite/resolve.py
Comment on lines +376 to +378
for citation, in_paren in zip(
citations, _in_parenthetical(citations), strict=True
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Callers that pass a generator or other one-shot iterable to resolve_citations now get a ValueError, where the base resolved them normally. _in_parenthetical(citations) uses up the iterator first. The zip(..., strict=True) then sees an empty first argument against an N-element list and raises. Fix: turn the input into a list once at the top of resolve_citations (citations = list(citations)) before computing the flags, so lists and any other iterable keep working.

Why this was flagged

This only matters if some downstream caller passes a non-list iterable, for example resolve_citations(c for c in get_citations(text) if ...). The parameter is annotated list, but that is not enforced, and the base loop for citation in citations accepted any iterable. I could not list the external callers from this checkout. At eyecite/resolve.py:376-378, _in_parenthetical(citations) runs its own for citation in citations and empties the generator. zip then gets an exhausted iterator plus a list of len(citations) flags. With strict=True that raises ValueError: zip() argument 2 is longer than argument 1, so the call crashes where the base returned resolutions. With strict=False it would instead silently return an empty dict.

Verification: Trigger: a caller passes a generator or other one-shot iterable to resolve_citations. In zip(citations, _in_parenthetical(citations), strict=True), _in_parenthetical uses up the generator first, so zip raises ValueError: zip() argument 2 is longer than argument 1 whenever N>0. On the base commit (b70a146) the code was just for citation in citations:, so any iterable worked.

Comment thread eyecite/resolve.py
Comment on lines +250 to +256
if citation.document is not document:
document = citation.document
stack = []
pos = 0
if document is None:
in_paren.append(False)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: An Id. after a parenthetical that cites a case by reference ("(quoting Foo at 3)") still resolves to Foo, not the citing case, so issue #348 stays open for that form. get_citations never sets document on ReferenceCitation objects. _in_parenthetical therefore marks every reference as not nested (resolve.py:254-256), and the reference then updates last_unnested_resolution. Fix: classify reference citations by their position in the shared document. Either set reference.document = document for the references built in get_citations (find.py:103), or reuse the current document when citation.document is None.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Take the text "Foo v. Bar, 1 U.S. 1 (1990). Smith v. Jones, 2 U.S. 2 (1991) (quoting Foo at 3). Id. at 4.". get_citations (find.py:103-104) adds the ReferenceCitation "Foo at 3", which extract_pincited_reference_citations builds with no document (find.py:205-214). Only find.py:134 sets citation.document, and only on the main citation. In _in_parenthetical, citation.document is not document resets document = None and the stack, and line 256 appends False for the reference. At resolve.py:412-413 the reference's resolution (Foo, from _resolve_reference_citation) becomes last_unnested_resolution, so the following unnested Id. goes to Foo rather than Smith. That is the same wrong answer the base gives, so the fix misses this parenthetical form. The reset also sets pos = 0, so the next citation rescans the plain_text from the start after every reference. No safeguard covers this: the 'no document means not nested' fallback was meant for hand-built citations, but every reference citation produced by get_citations has no document.

Verification: Pre-existing: the base resolves this Id. to Foo the same way. Trigger: "(quoting Foo at 3)" followed by an Id. outside the parenthetical. extract_pincited_reference_citations (find.py:205-214) never passes document, so it stays None (models.py:97). _in_parenthetical then marks the reference as not nested, so the Id. gets last_unnested_resolution, Foo, not Smith.

Address review on freelawproject#360:

- A paren now counts as an explanatory parenthetical only when it directly
  follows a citation (inside its full span, or after just a pin cite and
  complete parentheticals), and not when it opens with an -ing preposition
  such as "according", "including", or "regarding". Prose asides like
  "Courts agree (according to Foo, 1 U.S. 1)" no longer hide Foo from a
  following Id.
- resolve_citations() materializes its input once, so generators and other
  one-shot iterables work again instead of raising ValueError.
- get_citations() sets `document` on ReferenceCitation objects, so a
  reference inside a parenthetical, as in "(quoting Foo at 3)", is
  recognized as nested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

The Eyecite Report 👁️

Gains and Losses

There were 0 gains and 0 losses.

Total citations found: base 15988, PR 15988 (net +0).

Click here to see details.
id Gain Loss

Time Chart

image

Generated Files

Base (main) Output
PR Output
Full Output CSV

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Runs benchmark in fork PRs

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

Id. resolves to the citation inside a parenthetical

3 participants