-
Notifications
You must be signed in to change notification settings - Fork 0
Data Model
This page makes the internal structure transparent so you can extend it with
confidence: every field of every core object, what may live in a token's
annotations dict, how the pipeline's records relate to corpus tokens, what
fingerprint() and copy() actually guarantee, the persistence contracts, and
the invariants your own corpus or loader must hold (including exactly where a
wrong extension breaks a round-trip). Architecture is the
companion tour of the layering and the serialization/query surface; come here
when you are building on the objects themselves.
Every example below was run against the installed package; the output shown is the real output.
Corpus a collection of documents + shared context
├── documents: list[Document] one per inscription / tablet / text
│ ├── tokens: list[Token] the transliterated text stream, in order
│ ├── lines: list[list[int]] each physical line = a list of indices into tokens
│ └── meta: DocumentMeta site, period, scribe, support, findspot, name, …
├── sign_inventory: SignInventory | None the script's Sign objects, indexed
├── provenance: Provenance | None source, license, citation, notes
└── script_id: str "lineara", "greek", "custom", …
Three things to internalize:
-
linesholds indices, not tokens. A line is a list of positions intodocument.tokens;document.line_tokensresolves them for you. This is what lets a token belong to the physical layout without being stored twice. -
TokenandSignare frozen dataclasses (edit one withdataclasses.replace), each with a single mutable escape hatch: the per-tokenannotationsdict and the per-signattrsdict.Documentis mutable. -
Document ids are unique within a corpus. Constructing a
Corpuswith duplicate ids collapses them (keeping the last) with a warning;mergerefuses duplicates by default (dedupe="error").
import aegean
corpus = aegean.load("lineara") # Corpus
doc = corpus.get("HT13") # Document
print(type(corpus).__name__, "of", len(corpus), "documents")
print(doc.id, doc.script_id, "-", len(doc.tokens), "tokens on", len(doc.lines), "lines")
print("lines:", doc.lines) # each line = a list of indices into doc.tokens
tok = doc.tokens[0] # Token
print("first token:", tok.text, tok.kind, tok.signs)
print("line 2 text:", [t.text for t in doc.line_tokens[1]])Corpus of 1721 documents
HT13 lineara - 22 tokens on 8 lines
lines: [[0, 1, 2, 3, 4], [5, 6, 7], [8, 9], [10, 11, 12], [13, 14], [15, 16], [17, 18], [19, 20, 21]]
first token: KA-U-DE-TA TokenKind.WORD ('KA', 'U', 'DE', 'TA')
line 2 text: ['RE-ZA', '5', '¹⁄₂']
| Field | Type | Meaning |
|---|---|---|
text |
str |
the transliteration as written, e.g. KU-RO or Λόγος,
|
kind |
TokenKind |
word / logogram / numeral / separator / punct / unknown
|
signs |
tuple[str, ...] |
decomposed sign labels, e.g. ("KU", "RO"); empty for alphabetic text |
glyphs |
str | None |
the Unicode form, when known |
line_no |
int | None |
which physical line the token sits on |
position |
int | None |
the token's index within the document's token stream |
status |
ReadingStatus |
editorial certainty (§3); defaults to CERTAIN
|
alt |
tuple[str, ...] |
alternate readings (EpiDoc <app>/<rdg>); text is the preferred reading |
annotations |
dict[str, str] |
script-specific per-token facts; the extension surface (below) |
kind is load-bearing: document.words, word-scope queries, the review
export, and annotate_corpus all gate on TokenKind.WORD, so a token
mislabeled unknown silently disappears from every word-level analysis.
position is pure data (it may be None, and persistence never reorders by
it, see §6); it is also the stable join key the review loop uses, so keep it
equal to the token's list index when you build your own documents.
A gold-annotated token from the New Testament corpus shows a full set of fields in the wild:
import aegean
nt = aegean.load("nt")
doc = nt.get("John 1")
tok = doc.tokens[4] # λόγος, the fifth token of John 1:1
print("text :", tok.text)
print("kind :", tok.kind)
print("signs :", tok.signs)
print("glyphs :", tok.glyphs)
print("line_no :", tok.line_no)
print("position :", tok.position)
print("status :", tok.status)
print("alt :", tok.alt)
for k, v in sorted(tok.annotations.items()):
print(f"annotations[{k!r}] = {v!r}")text : Λόγος,
kind : TokenKind.WORD
signs : ()
glyphs : Λόγος,
line_no : 1
position : 4
status : ReadingStatus.CERTAIN
alt : ()
annotations['gloss'] = 'a word, speech, divine utterance, analogy'
annotations['lemma'] = 'λόγος'
annotations['morph'] = 'N-NSM'
annotations['normalized'] = 'Λόγος'
annotations['ref'] = 'John.1.1'
annotations['strongs'] = '3056'
annotations['upos'] = 'NOUN'
annotations is a flat dict[str, str] (keep the values strings; the
built-in writers do, including the booleans, stored as "true"/"false"). It
is covered by fingerprint(), survives the JSON and SQLite round-trips
unchanged, and spreads into token-level DataFrames. The keys in use, by who
writes them:
| Written by | Keys | Meaning |
|---|---|---|
| the NT corpus (gold) |
lemma, morph, strongs, gloss, normalized, upos, ref
|
edition-supplied annotation |
greek.annotate_corpus |
lemma, upos, lemma_source, lemma_known
|
machine analysis + its evidence class (§4) |
aegean.io.from_review_table |
<field>__pred, review_status, reviewed_by, review_note
|
the human-correction audit trail (§7.3) |
| you | anything else | your own per-token facts |
The <field>__pred convention is the audit trail: when a reviewer's
correction is applied, the corrected value replaces the machine value under
its normal key (lemma, upos, morph or feats) and the machine value the
reviewer saw is preserved under lemma__pred / upos__pred / morph__pred.
Nothing is overwritten silently; treat any key ending in __pred as reserved
for that convention. lemma_source holds a LemmaSource evidence-class value
(attested / neural / rule / seed / paradigm / identity / unresolved /
punct), and lemma_known is its derived boolean; the review export's
"needs review" triage reads them.
Every token carries an editorial certainty following Leiden / EpiDoc conventions. The four states are exhaustive, and unknown values are rejected at construction, so the enum is a real invariant:
| Status | Meaning | Set by |
|---|---|---|
certain |
securely read | the default |
unclear |
damaged but read | Leiden underdot; EpiDoc <unclear>; erasures (⟦ ⟧); illegible-sign marks in the Cypriot apparatus |
restored |
editorially supplied | Leiden [ ]; EpiDoc <supplied>
|
lost |
not preserved / lacuna | Leiden [---]; EpiDoc <gap> or <supplied reason="undefined">
|
Concretely: the bundled Linear A and Cypriot loaders decode their editions'
Leiden apparatus; the six epigraphy corpora (isicily, iip, iospe,
igcyr, edh, ddbdp) decode EpiDoc markup, and a word touched by more than
one state carries the most severe one; a bring-your-own EpiDoc import
populates them from the same elements, and the EpiDoc writer emits them back
out; Corpus.from_records takes a "status" string per token (§7.1). See
Using Critical Editions for working with them.
import aegean
from collections import Counter
corpus = aegean.load("lineara")
print(dict(Counter(t.status.value for d in corpus for t in d.tokens)))
print([(t.text, t.status.value) for t in corpus.get("HT23b").tokens][:8]){'certain': 5734, 'lost': 552, 'unclear': 120}
[('NI-RA', 'certain'), ('CYP', 'certain'), ('¹⁄₃', 'certain'), ('OLE', 'certain'), ('¹⁄₃', 'certain'), ('MI+JA+RU', 'certain'), ('MU', 'certain'), ('QA2+[?]+PU', 'unclear')]
A common confusion. Token is corpus data: what an edition says, stored
in documents, persisted and cited. greek.TokenRecord is pipeline output:
one token's analysis from greek.pipeline(), ephemeral, never stored in a
corpus. They share nothing structurally.
TokenRecord field |
Meaning |
|---|---|
sentence |
0-based sentence number within the input text |
index |
1-based token position within its sentence |
text |
the token |
upos |
universal POS tag |
lemma |
the lemma |
lemma_source |
the lemma's evidence class (LemmaSource) |
head |
index of the head record in the same sentence; 0 = root, None = no parse |
relation |
dependency relation. Note the name: it is relation, not deprel
|
xpos, feats
|
9-char positional tag / UD FEATS; filled by the neural pipeline only |
lemma_known |
derived property: False when the lemma needs human review |
from aegean import greek
records = greek.pipeline("ὁ λόγος σὰρξ ἐγένετο καὶ ἐσκήνωσεν ἐν ἡμῖν.")
for r in records:
print(f"{r.sentence} {r.index} {r.text:10} {r.upos:6} {r.lemma:10} "
f"{r.lemma_source.value:10} known={r.lemma_known!s:5} "
f"head={r.head} relation={r.relation} xpos={r.xpos} feats={r.feats}")0 1 ὁ DET ὁ seed known=True head=None relation=None xpos=None feats=None
0 2 λόγος NOUN λόγος seed known=True head=None relation=None xpos=None feats=None
0 3 σὰρξ NOUN σὰρξ unresolved known=False head=None relation=None xpos=None feats=None
0 4 ἐγένετο NOUN ἐγένετο unresolved known=False head=None relation=None xpos=None feats=None
0 5 καὶ CCONJ καί seed known=True head=None relation=None xpos=None feats=None
0 6 ἐσκήνωσεν NOUN ἐσκήνωσεν unresolved known=False head=None relation=None xpos=None feats=None
0 7 ἐν ADP ἐν seed known=True head=None relation=None xpos=None feats=None
0 8 ἡμῖν NOUN ἐγώ seed known=True head=None relation=None xpos=None feats=None
0 9 . PUNCT . punct known=True head=None relation=None xpos=None feats=None
This is the zero-dependency baseline being honest: the forms it cannot ground
come back unresolved with the surface form as the lemma, never a fabricated
citation form. With the neural pipeline active (greek.use_neural_pipeline())
every field fills and grounded lemmas read neural. The evidence classes are
visible one form at a time too:
from aegean import greek
for form in ['ἀνθρώπου', 'δούλου', 'γράφομεν', 'ἐσκήνωσεν']:
lemma, source = greek.lemmatize_sourced(form)
print(f'{form:12} -> {lemma:12} {source.value:12} needs_review={greek.needs_review(source)}')ἀνθρώπου -> ἄνθρωπος seed needs_review=False
δούλου -> δούλος rule needs_review=False
γράφομεν -> γράφω rule needs_review=False
ἐσκήνωσεν -> ἐσκήνωσεν unresolved needs_review=True
The bridge between the two worlds is greek.annotate_corpus (§7.3): it runs
the active pipeline over a corpus's existing word tokens and writes lemma,
upos, and the evidence class into Token.annotations, so pipeline output
becomes corpus data you can export, review, and persist. For reading parses
themselves, see Reading a Parse and Greek NLP.
corpus.fingerprint() is a stable sha256 over everything a token-level
analysis can see: the script id, the provenance data_version, each
document's id, and every token's text, kind, status, signs, glyphs,
alt, and annotations (plus any subset: / merged: / appended:
provenance note). Fields are length-prefixed, so no crafted value can collide
two corpora. It deliberately excludes document metadata (site, period, …),
lines, line_no, position, translations, and transcriptions: two corpora
with equal fingerprints have the same analysable content in the same token
order, which is why it is the cache key for aegean.cache-memoised analyses.
It is not a byte-equality check on the whole object.
corpus.copy() returns a structurally independent corpus: fresh document,
token, line, and translation containers, and (the part that matters) a fresh
annotations dict per token and attrs dict per sign. Mutating a copy never
leaks into the original, a sibling copy, or a later load() of the same
cached corpus; load() itself hands out a copy for exactly this reason. The
frozen scalars, DocumentMeta, and Provenance are shared, which is safe
because they are immutable.
import aegean
a = aegean.load("lineara")
b = a.copy()
print("same fingerprint after copy:", a.fingerprint() == b.fingerprint())
# the hash folds provenance.data_version, so it is stable within a release but
# changes across releases — compare fingerprints, never hard-code the hex value
# annotations is the one mutable slot on a (frozen) Token — and the copy owns its own dicts
b.get("HT13").tokens[0].annotations["my_note"] = "checked against GORILA"
print("original untouched:", a.get("HT13").tokens[0].annotations)
print("fingerprints now differ:", a.fingerprint() != b.fingerprint())
# a fresh load is unaffected too: load() itself hands out a copy
c = aegean.load("lineara")
print("fresh load clean:", c.get("HT13").tokens[0].annotations == {})same fingerprint after copy: True
original untouched: {}
fingerprints now differ: True
fresh load clean: True
Three formats, three different promises:
-
JSON (
to_json/from_json) is lossless. Every token field, the lines, full document metadata, the sign inventory, and provenance survive exactly. (to_dictis the lossy quick-interop summary; don't round-trip through it.) -
SQLite (
to_sql/from_sql,aegean.db) is lossless and adds FTS search. The tokens table carries an explicittoken_ordercolumn: the token's index in the document's list, written at save time. Reload order comes fromtoken_order, never fromposition, so aposition=Nonetoken (SQLNULL, which would otherwise sort first) or out-of-order positions survive in place. Databases written before 0.19.4 lack the column; they are read via apositionfallback and migrated in place on the firstappend=Truewrite. -
Schema versions gate forward-compatibility only.
SCHEMA_VERSION(stored as_meta.schemaVersionin JSON,schema_versionin the SQLitemetatable and onProvenance) is bumped only for changes an older reader would misread. Loading a file with a newer version raises aValueErrornaming the fix; an older or missing version loads normally. Additive optional fields (e.g.edition_fidelity) deliberately do not bump it.
import aegean
from aegean import Corpus
corpus = Corpus.from_records([
{"id": "X1", "text": "KU-RO 10", "meta": {"site": "My site"}},
{"id": "X2", "lines": [["A-DU", {"text": "5", "status": "unclear"}]]},
], script_id="lineara")
# JSON: lossless both ways
back = Corpus.from_json(corpus.to_json())
print("JSON round-trip lossless:", back.fingerprint() == corpus.fingerprint())
print("status survived:", back.get("X2").tokens[1].status)
# SQLite: same contract, via a file
import tempfile, pathlib
db = pathlib.Path(tempfile.mkdtemp()) / "myfind.db"
corpus.to_sql(db)
again = Corpus.from_sql(db)
print("SQLite round-trip lossless:", again.fingerprint() == corpus.fingerprint())JSON round-trip lossless: True
status survived: ReadingStatus.UNCLEAR
SQLite round-trip lossless: True
And the token_order contract under stress: a hand-built document whose
middle token has no position at all.
import pathlib, tempfile
from aegean import Corpus, Document, Provenance, Token, TokenKind
# a document whose SECOND token has no position (e.g. an editorial insertion)
doc = Document(
id="D1", script_id="custom",
tokens=[
Token(text="alpha", kind=TokenKind.WORD, position=0),
Token(text="beta", kind=TokenKind.WORD, position=None),
Token(text="gamma", kind=TokenKind.WORD, position=1),
],
lines=[[0, 1, 2]],
)
corpus = Corpus([doc], provenance=Provenance(source="demo"), script_id="custom")
db = pathlib.Path(tempfile.mkdtemp()) / "order.db"
corpus.to_sql(db)
back = Corpus.from_sql(db)
print([ (t.text, t.position) for t in back.get("D1").tokens ])
print("list order preserved:", back.fingerprint() == corpus.fingerprint())[('alpha', 0), ('beta', None), ('gamma', 1)]
list order preserved: True
Corpus.from_records turns plain dicts into first-class corpus data: each
record needs an "id" and its text as "lines" (list of lines, each a list
of tokens), "words" (one flat line), or "text" (whitespace-tokenized). A
token is a string or a dict {"text": …, "kind": …, "status": …, "alt": …};
kind is inferred when omitted (numerals by parseability, the rest words), and
hyphenated tokens get their signs split. It maintains the invariants of §7.4
for you: positions are assigned sequentially and lines index correctly. The
corpus built in §6 is exactly this. If your material is a text file or CSV,
the aegean.io importers and aegean import build the same structure.
A loader makes your corpus loadable by name everywhere a registered id works
(Python, CLI, TUI, MCP). Attach a real Provenance so citations stay honest:
import aegean
from aegean import Corpus, Provenance
from aegean.core.corpus import register_loader
prov = Provenance(
source="My 2026 field notebook",
license="CC BY 4.0",
citation="Pavlicek, R. (2026). Inscriptions from My Site.",
)
mine = Corpus.from_records(
[{"id": "MS1", "text": "KU-RO 10", "meta": {"site": "My site"}}],
script_id="lineara", provenance=prov,
)
register_loader("myfind", lambda: mine)
loaded = aegean.load("myfind")
print(len(loaded), "document(s):", loaded.get("MS1").words[0].text)
print(loaded.cite())
# load() returns a copy, so a caller's edits never reach the registered instance
loaded.get("MS1").tokens[0].annotations["scratch"] = "x"
print("registered instance clean:", aegean.load("myfind").get("MS1").tokens[0].annotations == {})1 document(s): KU-RO
Pavlicek, R. (2026). Inscriptions from My Site.
registered instance clean: True
(A new script, as opposed to a new corpus, is a plugin: subclass
aegean.core.Script, register() it, and register a loader; the core never
imports scripts. See Architecture.)
Adding keys to Token.annotations is always round-trip-safe through JSON and
SQLite; the built-in machinery only ever adds keys, never strips unknown ones.
The full annotate → export → correct → apply loop, showing the <field>__pred
audit trail land on the token:
import csv, pathlib, tempfile
from aegean import Corpus, greek
from aegean.io import to_review_table, from_review_table
corpus = Corpus.from_records(
[{"id": "frag1", "text": "ὁ λόγος σὰρξ ἐγένετο"}], script_id="greek"
)
annotated = greek.annotate_corpus(corpus) # fills lemma/upos + evidence class
tok = annotated.get("frag1").tokens[3] # ἐγένετο — the baseline can't resolve it
print("after annotate:", dict(sorted(tok.annotations.items())))
work = pathlib.Path(tempfile.mkdtemp())
n = to_review_table(annotated, work / "review.csv", only_needs_review=True)
print("rows exported:", n)
# simulate the reviewer: fill correct_lemma on the ἐγένετο row
with open(work / "review.csv", encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
for row in rows:
if row["token"] == "ἐγένετο":
row["correct_lemma"] = "γίγνομαι"
row["correct_pos"] = "VERB"
row["reviewer_note"] = "aor. mid. of γίγνομαι"
with open(work / "review.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(rows)
fixed = from_review_table(work / "review.csv", annotated, reviewer="RP")
tok = fixed.get("frag1").tokens[3]
print("after apply:", dict(sorted(tok.annotations.items())))
print("provenance note:", fixed.provenance.notes[-1])after annotate: {'lemma': 'ἐγένετο', 'lemma_known': 'false', 'lemma_source': 'unresolved', 'upos': 'NOUN'}
rows exported: 2
after apply: {'lemma': 'γίγνομαι', 'lemma__pred': 'ἐγένετο', 'lemma_known': 'false', 'lemma_source': 'unresolved', 'review_note': 'aor. mid. of γίγνομαι', 'review_status': 'corrected', 'reviewed_by': 'RP', 'upos': 'VERB', 'upos__pred': 'NOUN'}
provenance note: review: 1 tokens corrected by RP (2026-07-10)
Note what the loop relies on: the token had a position (a token without one
is excluded from the export, because a correction on it could never be
applied), and the applied-to corpus still had the same token text at that
position (a mismatch raises rather than landing a correction on the wrong
word). The full workflow, including the CLI form, is on
When the Tool Is Wrong.
If you construct Document/Token objects yourself (rather than through
from_records or the importers), these are the contracts the rest of the
toolkit assumes:
-
Every
linesindex is a valid token index (0 ≤ i < len(tokens)). The JSON and SQLite readers validate this and refuse a malformed file by name; an in-memory violation surfaces later as anIndexErrorfromline_tokensor an export. -
positionshould be the token's index in the document's list, unique per document. Persistence toleratesNoneand disorder (§6), but the review join key isdoc_id+position: aNonedrops the token from review export, and a duplicated position can land one correction on more than one token. -
statusmust be one of the fourReadingStatusvalues andkindone of the sixTokenKindvalues; unknown strings raise at construction, so invent nothing here. -
kindmust be honest: word-level analysis, review export, and annotation all gate onTokenKind.WORD. -
Keep
annotationsvalues strings, and stay off the reserved conventions (<field>__pred,lemma_source,lemma_known,review_status,reviewed_by,review_note) unless you mean them. - Document ids must be unique within a corpus (duplicates collapse with a warning, keeping the last).
The failure modes are deliberately loud rather than silent:
import json
from aegean import Corpus, Document, Token, TokenKind
# (a) a lines entry pointing past the token list: caught at load, named by document
bad = Corpus([Document(id="D1", script_id="custom",
tokens=[Token(text="alpha", kind=TokenKind.WORD, position=0)],
lines=[[0, 7]])], script_id="custom")
blob = json.loads(bad.to_json()) # serializes fine — the writer doesn't validate
try:
Corpus.from_dict(blob)
except ValueError as e:
print("ValueError:", e)
# (b) an unknown status value: rejected before a Token is ever built
try:
Corpus.from_records([{"id": "X", "lines": [[{"text": "A", "status": "probable"}]]}])
except ValueError as e:
print("ValueError:", e)
# (c) a corpus file from a future pyaegean: refused with the fix named
blob["_meta"]["schemaVersion"] = 99
try:
Corpus.from_dict(blob)
except ValueError as e:
print("ValueError:", e)ValueError: document 'D1': line 0 references token index 7, but the document has 1 token(s); the source is malformed
ValueError: 'probable' is not a valid ReadingStatus
ValueError: this corpus file uses schema version 99, but this pyaegean understands up to 1 — upgrade pyaegean to read it
The one sharp edge that is not loud, worth restating: an invalid lines
list on an in-memory corpus you built by hand serializes without complaint
(example (a) above writes fine and only fails on read). Validate by
round-tripping once (Corpus.from_json(corpus.to_json())) before you ship a
corpus file to anyone else.
See also: Architecture · For Specialists · Using Critical Editions · When the Tool Is Wrong · Reading a Parse · Data & Provenance · Greek NLP
Start here
Aegean scripts
Greek
Capabilities
Evaluation & methodology
Reference