cane.loads(output, text=..., variant=...) reads a model’s CANE output and finds each record’s place in the text. It never raises for problems in the output: everything that could not be grounded comes back as a ParseIssue.
import cane_format as cane
text = "Take aspirin with food. If pain persists, take aspirin again."
output = "Entities:\n`aspirin` is a DRUG\n`aspirin` is a DRUG\n`fever` is a ADR"
result = cane.loads(output, text=text, variant="base", labels={"DRUG", "ADR"})
[(e.label, e.spans) for e in result.entities], [str(i) for i in result.issues]
([('DRUG', (Span(5, 12),)), ('DRUG', (Span(47, 54),))],
["line 4: not_found: part 'fever' not found in the text"])
Grounding: where a record lands
The parser grounds what the model wrote; it never judges whether a prediction is plausible.
- Reading order. Each record takes the earliest unused occurrence at or after the previous record’s position, else the earliest unused one anywhere. Repeated mentions and nested entities land where a reader expects them. (On the CANE paper’s model outputs this rule beat “earliest occurrence anywhere” in every case, including outputs grouped by label.)
- Anchors select the occurrence when there are several. If an anchor is not in the text, or does not contain its part, the record is reported as
anchor_not_found.
- Overlap never rejects. Nested and overlapping entities are kept. A record that would repeat an earlier entity exactly is reported as
duplicate.
- Discontinuous parts are grounded together: all parts or nothing, and parts may not overlap each other.
Strict by default
Every leniency is an explicit switch, off by default, and the parser never tries a looser setting on its own:
labels= |
Unknown labels keep the entity and add an invalid_label issue. |
match="normalized" |
Match after Unicode NFC normalisation. |
match="casefold" |
Match ignoring case. |
anchor_fallback=True |
Ground the bare part when its anchor is not found. |
strip_list_markers=True |
Accept lines starting with - or 1.. |
handles=True |
Records carry handles (T1 …), which become entity IDs. |
prefer_whole_words=True |
See below. |
All of them can be bundled in one cane.ParseOptions, which is also recorded on every result as result.settings.
Choosing an occurrence
Without anchors (Base, Disco), only the reading order decides, and the earliest occurrence can be inside a longer word:
text = "Maurice Tate against Northamptonshire at Northampton"
record = "`Northampton` is a LOC"
for prefer in (False, True):
e = cane.loads(record, text=text, variant="base", prefer_whole_words=prefer).entities[0]
print(f"prefer_whole_words={prefer}:", text[: e.start] + "[" + text[e.start : e.end] + "]" + text[e.end :])
prefer_whole_words=False: Maurice Tate against [Northampton]shire at Northampton
prefer_whole_words=True: Maurice Tate against Northamptonshire at [Northampton]
prefer_whole_words=True tries occurrences that neither start nor end between two letters or digits first. It only reorders candidates: an entity that occurs only inside a word is still found, and in scripts written without spaces nothing changes.
Issues
empty_output |
The output is empty: the generation probably failed. |
malformed_line |
The line is not a record of this variant. |
not_found |
A part does not occur in the text. |
anchor_not_found |
An anchor does not occur in the text, or does not contain its part. |
part_count_mismatch |
The number of anchors differs from the number of parts. |
unknown_handle |
A handle does not refer to an entity. |
invalid_label |
The label is not in labels=. |
invalid_span |
The parts cannot form a valid entity. |
duplicate |
The record repeats an earlier entity exactly. |
Each issue carries the raw line, its line number and the candidate spans that were considered, so you can see why a line failed.