Repository navigation
Conversation
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 review |
| # 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") |
There was a problem hiding this comment.
🔴 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in 5371201: resolve_citations() now does citations = list(citations) up front, and there's a new test that passes a generator.
There was a problem hiding this comment.
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.
| for citation, in_paren in zip( | ||
| citations, _in_parenthetical(citations), strict=True | ||
| ): |
There was a problem hiding this comment.
🟡 (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.
| if citation.document is not document: | ||
| document = citation.document | ||
| stack = [] | ||
| pos = 0 | ||
| if document is None: | ||
| in_paren.append(False) | ||
| continue |
There was a problem hiding this comment.
🟣 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>
The Eyecite Report 👁️Gains and LossesThere were 0 gains and 0 losses. Total citations found: base 15988, PR 15988 (net +0). Click here to see details.
Time ChartGenerated Files |

Fixes
Fixes: #348
Summary
This PR stops
resolve_citations()from treating a citation inside an explanatory parenthetical as the antecedent of a laterId.. With the Sidhu passage from the issue, bothId.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'sdocument.plain_textand 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 asduring,including,according,regarding,concerning,notwithstanding). An ordinary prose aside like(according to Foo, 1 U.S. 1)is not nested, so a followingId.still resolves to Foo.resolve_citations()then keeps two antecedents:Id.outside a parenthetical resolves against the last citation that was also outside one;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.. Theresolve_id_citationcallback signature is unchanged.Gotchas / compromises
(See Smith, 1 U.S. 1.) Id.are not treated as nested, soId.still refers to Smith. Explanatory parentheticals that don't start with an-ingword, e.g.(per curiam) (Smith v. Jones ...), aren't detected and fall back to the current behavior.)are ignored, and an unclosed(only affects the citations it actually precedes. Citations without adocument, such as hand-built ones, are treated as unnested.Id. at 827 (quoting …), the pin cite isn't captured, because(quotingis aStopWordTokenandextract_pin_citestops before it. TheId.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
resolve_citations()materialises its input once so generators work;get_citations()now setsdocumenton reference citations. Each has a regression test.test_id_resolution_skips_parentheticalsuses the issue's passage plus nested parentheticals, a parenthetical closing before a new antecedent, anid.inside a parenthetical, a parenthesized citation sentence, and an unclosed paren. It fails onmainwith exactly the issue's symptom.python -m unittest discover -s tests -p 'test_*.py': 56 tests, OK.ruff0.11.8 check and format, andmypy ., are clean.main): 0 gains, 0 losses, 15,988 citations on both sides. That's expected, since the benchmark measures extraction, not resolution.Id.resolutions on the same corpus (130 opinions, 595Id.cites). 37 changed:Id.aftersee 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