CANE (Context-Anchored Natural Expression) is an output format for named entity recognition with large language models, introduced in CANE is a Format (Findings of EMNLP 2026). Instead of character offsets, per-token tags or a marked-up copy of the input, the model writes one short statement per entity, copied verbatim from the text:
Entities:
`metastatic breast cancer` is a DISEASE found in `metastatic breast cancer cells`
`breast cancer cells` is a CELL found in `metastatic breast cancer cells caused`
`headache` and `nausea` are a ADR found in `caused headache and` and `and nausea`
Models are good at copying visible text and bad at counting, so CANE never asks for offsets, token numbers or codes. This library turns those statements back into character spans.
A record, piece by piece
Each line is a record and describes one entity. The last line above, taken apart:
| parts |
`headache`, `nausea` |
The entity’s text, copied verbatim. Several parts make one discontinuous entity. |
| part connective |
and (between the parts) |
Joins the parts. |
| copula |
are |
is or are. |
| article |
a |
Optional. |
| label |
ADR |
The entity type: one token. |
| anchor intro |
found in |
Starts the anchors. |
| anchors |
`caused headache and`, `and nausea` |
One per part, in the same order: the part with some text around it, copied verbatim, saying where the part is. |
| anchor connective |
and (between the anchors) |
Joins the anchors. |
Anchors matter when a string occurs more than once: in a text that mentions aspirin twice, `aspirin` is a DRUG could mean either, while `aspirin` is a DRUG found in `take aspirin again` means one.
The four variants
| Base |
exactly one |
none |
| Disco |
one or more |
none |
| Context |
exactly one |
exactly one |
| Full |
one or more |
one per part, in the same order |
The variant is always given explicitly, when writing and when parsing: a line with an anchor is required in Full but wrong in Base, so the parser must know what the model was asked for. The same entity in two variants:
import cane_format as cane
text = "It caused headache and nausea after the first dose."
doc = cane.Document(
text=text,
entities=(cane.Entity(spans=[(10, 18), (23, 29)], label="ADR"),),
)
for variant in ("disco", "full"):
print(f"--- {variant}")
print(cane.dumps(doc, variant=variant))
--- disco
Entities:
`headache` and `nausea` are a ADR
--- full
Entities:
`headache` and `nausea` are a ADR found in `caused headache and` and `and nausea after`
Grammar
output ::= HEADER | [HEADER NEWLINE] ( EMPTY | record { NEWLINE record } )
record ::= [HANDLE] parts COPULA [ARTICLE] LABEL [ANCHOR_INTRO anchors]
parts ::= QUOTE text QUOTE { PART_CONNECTIVE QUOTE text QUOTE }
anchors ::= QUOTE text QUOTE { ANCHOR_CONNECTIVE QUOTE text QUOTE }
[ … ] is optional, { … } repeats zero or more times. The symbols and their default spelling (every one can be changed with a dialect):
HEADER |
Entities: |
header |
EMPTY |
NONE |
empty |
QUOTE |
` (backtick) |
quote_open, quote_close |
COPULA |
is, are |
copula_singular, copula_plural |
ARTICLE |
a, an |
articles |
ANCHOR_INTRO |
found in |
anchor_intro |
PART_CONNECTIVE |
and |
part_connective |
ANCHOR_CONNECTIVE |
and |
anchor_connective |
HANDLE |
T1, T2, … |
handle_prefix |
NEWLINE |
a line break |
record_separator |
LABEL |
any token without whitespace |
|
text |
any text |
|
The grammar allows anchors and several parts everywhere; the variant then restricts them as in the table above.
Rules for a whole output
- One record per line. Blank lines and whitespace at the start or end of a line are ignored.
- The header is optional when parsing. If present, it must be the first line, spelled exactly (
entities: is not the header). dumps always writes it.
- No entities is written as
NONE (in any case) on its own or after the header, or as the header alone. Entities: NONE on a single line is not one of these and is reported.
- An empty output gives no entities too, but is reported as
empty_output: it usually means the generation failed.
- A bad line never stops parsing. It is reported, and the next line is read as usual.
Rules for a record
- Parts and anchors are verbatim text. Whitespace inside them matches any run of whitespace in the text, so
`caused headache` still matches caused headache.
is and are are both accepted, whatever the number of parts. dumps writes is for one part and are for several.
- The article is optional, and any article of the dialect is accepted (
is an ADR, is ADR). dumps writes the first one, a.
- The label is one token: everything up to the next whitespace, punctuation included (
is a ADR. gives the label ADR.). Labels are checked only if you pass labels=.
- An anchor must contain its part. The anchor is found in the text first, then the part inside it.
- Handles (
T1, T2 before the parts) are accepted only with handles=True, and then every record needs one. The handle becomes the entity’s ID; a handle used twice is reported.
- There is no escaping. A part may contain the quote character; records are split only at a closing quote, connective and opening quote (
` and `), so only that exact sequence cannot appear inside a part.
What a wrong line looks like
Each problem becomes a ParseIssue with the line number and a reason, and the other lines are still read:
text = "Aspirin relieved the headache and the nausea."
output = """Entities:
`Aspirin` is a DRUG found in `Aspirin relieved`
`headache` is a ADR
`headache` and `nausea` are a ADR found in `the headache and`
`fever` is a ADR found in `the fever`
Aspirin is a DRUG found in `Aspirin relieved`"""
result = cane.loads(output, text=text, variant="full")
print([(e.label, text[e.start:e.end]) for e in result.entities])
for issue in result.issues:
print(issue)
[('DRUG', 'Aspirin')]
line 3: malformed_line: missing anchor in CANE-full
line 4: part_count_mismatch: 2 part(s) but 1 anchor(s)
line 5: anchor_not_found: anchor 'the fever' was not found for part 'fever'
line 6: malformed_line: not a CANE record
The parsing guide lists every issue kind and explains how a record is placed when its text occurs more than once.
Dialects
Every literal (Entities:, NONE, the backtick, is / are, a / an, found in, and, the handle prefix T) comes from a Dialect. The default is cane.ENGLISH; another language or other delimiters are one object away:
german = cane.Dialect(
header="Entitäten:", empty="KEINE", copula_singular="ist", copula_plural="sind",
articles=("ein", "eine"), anchor_intro="gefunden in", part_connective="und",
anchor_connective="und",
)
print(cane.dumps(doc, variant="full", dialect=german))
Entitäten:
`headache` und `nausea` sind ein ADR gefunden in `caused headache and` und `and nausea after`
Pass the same dialect to loads to parse such output.