Skip to content

aegean.analysis

analysis

Script-agnostic + Aegean-specific analysis over the core model.

Ported faithfully from the Linear A Research Workbench (src/lib/*.ts) and checked against shared golden fixtures so the Python port can't silently diverge. Methods over the undeciphered Linear A material are exploratory — see each function's docstring.

BalanceCheck dataclass

BalanceCheck(stated_total: float, computed_sum: float, item_count: int, difference: float, balances: bool, marker: str, total_line_index: int)

One total line reconciled against the item lines feeding it: the stated total, the computed sum, their signed difference (computed − stated), whether they balance, the total marker (e.g. KU-RO), and the index of the total line.

AlignCell dataclass

AlignCell(a: str, b: str, op: AlignOp)

One aligned position: a char from a (or "" for ins), a char from b (or "" for del), and the operation that relates them.

ExclusivityRow dataclass

ExclusivityRow(group: str, gloss: str, word: str, count: int, word_total: int, exclusivity: float)

One (ideogram group, co-occurring word) pair: the in-group co-occurrence count, the word's word_total across all groups, and exclusivity = count / word_total (1.0 = the word is seen only with this group).

PhoneticComparison dataclass

PhoneticComparison(word_a: str, word_b: str, script_a: str, script_b: str, phonemes_a: str, phonemes_b: str, distance: float, similarity: float, alignment: tuple[AlignCell, ...])

One cross-script comparison: the two words, their script ids, their romanized phoneme strings, the normalized distance (0 = identical, 1 = wholly different), its similarity complement, and the per-segment alignment.

PhoneticClasses dataclass

PhoneticClasses(vowels: str, consonant_classes: tuple[tuple[str, ...], ...])

Concrete vowel set + consonant-class tables for the distance metric.

PhoneticScheme dataclass

PhoneticScheme(interdentals: str = 'dental', pharyngeal_h: str = 'velar', voiced_postalveolar: str = 'sibilant', strip_notation: bool = True)

The four typologically ambiguous decisions, exposed for tuning.

PhoneticWeights dataclass

PhoneticWeights(vowel: float = 0.3, same_class: float = 0.5, far: float = 1.0, indel: float = 1.0)

Tunable substitution / indel costs, kept in [0,1] so the normalized distance stays in [0,1].

EdgeBiasRow dataclass

EdgeBiasRow(affix: str, edge_count: int, interior: int, g2: float)

One affix's edge-bias result: its edge-slot count, its interior-window count, and the signed G².

PositionalRow dataclass

PositionalRow(word: str, count: int, initial: int, medial: int, final: int, dominant: str, g2: float)

A word's positional profile: token count, its slot tallies (initial / medial / final), its dominant slot, and the signed positional-bias G².

Productivity dataclass

Productivity(affix: str, count: int, distinct: int, hapax: int, productivity: float)

One affix's productivity: count (token sum of bearing words), distinct word types, hapax types (attested once), and Baayen's productivity P = hapax / count.

SuccessorRow dataclass

SuccessorRow(stem: str, variety: int, parent_variety: int, ratio: float)

A prefix's successor variety: variety distinct following signs, the parent_variety of the prefix one sign shorter, and ratio = variety / mean branching at this sign-depth.

SuccessorVariety dataclass

SuccessorVariety(rows: list[SuccessorRow], total: int)

Successor-variety rows (passing the thresholds, strongest ratio first) and total = how many passed.

ClusterMember dataclass

ClusterMember(word: str, count: int, suffix: str)

A word in a cluster, with the signs it appends beyond the cluster stem ("" for the stem itself; "≠" flags a member that doesn't actually extend the stem).

MorphCluster dataclass

MorphCluster(stem: str, members: tuple[ClusterMember, ...], total_count: int, suffixes: tuple[str, ...])

A stem and its productive-suffix derivations.

CAPoint dataclass

CAPoint(label: str, x: float, y: float, mass: float)

A row or column in the CA plane: x/y principal coordinates and mass (its marginal share of the table, which drives point size).

CAResult dataclass

CAResult(rows: list[CAPoint], cols: list[CAPoint], inertia: tuple[float, float], total_inertia: float)

Correspondence-analysis biplot: rows and cols in a shared plane, the share of total inertia on axes 1 and 2, and total_inertia.

DendroMerge dataclass

DendroMerge(a: int, b: int, height: float, members: list[str], support: float)

One merge: the two child cluster ids, the height (cosine distance) it joined at, the sorted member labels it creates, and bootstrap support (0–1; 1 for the trivially-present root).

DendroResult dataclass

DendroResult(labels: list[str], merges: list[DendroMerge], order: list[str])

A dendrogram: the leaf labels, the merges (leaves are ids 0..n-1; merge k creates id n+k), and the left→right leaf order.

CompiledSignPattern dataclass

CompiledSignPattern(tokens: tuple[str, ...], has_double_star: bool)

A parsed sign-pattern query: the normalized sign tokens (with * = one sign and ** = zero-or-more wildcards) and whether the pattern contains a **.

FieldDef dataclass

FieldDef(label: str, scope: Scope, kind: FieldKind)

A queryable field: its display label, scope (inscription/word), and value kind.

FilterRow dataclass

FilterRow(field: str, value: Any, connector: Connector | None = None, negate: bool = False)

One query row. connector joins this row to the running result within its scope (ignored on the first row); negate flips the row's own test.

QueryResults dataclass

QueryResults(inscriptions: list[Document], words: list[tuple[str, int]], provenance: Provenance | None = None, description: str = '')

A query's result set: the matching inscriptions and/or (word, count) pairs.

In words, count is the word's document frequency: the number of distinct inscriptions the word occurs in (e.g. ("KU-RO", 34) means KU-RO appears in 34 separate documents), not how many times it is written. A word repeated within one inscription still counts that inscription once. This differs from Corpus.word_frequencies, whose count is the token frequency (every occurrence). The list is sorted by descending count, then by word.

Corpus.query attaches the corpus's provenance and a description of the filters, so cite can cite the exact result set used in a paper.

cite

cite(style: str = 'plain') -> str

Cite this exact result set: the source plus the query that produced it.

style: "plain" (one line), "bibtex" (a @misc entry), or "apa". Raises ValueError when the results carry no provenance (results from eval_query directly rather than Corpus.query).

to_corpus

to_corpus(source: 'Corpus') -> 'Corpus'

Rebuild a reusable Corpus from this result set's matched inscriptions.

Carries source's sign inventory and script id, and stamps a subset: provenance note so cite on the saved corpus names the query. A words-only result has no inscriptions, so it yields an empty corpus — query with output="inscriptions".

WordEntry dataclass

WordEntry(count: int, inscription_ids: tuple[str, ...], sites: frozenset[str] = frozenset())

Per-word index entry: the documents a (multi-sign) word appears in.

BootstrapCI dataclass

BootstrapCI(estimate: float, low: float, high: float, level: float, n_resamples: int)

A percentile bootstrap interval: the statistic on the full corpus (estimate) and the [low, high] band holding level of the resampled values.

Chao1Result dataclass

Chao1Result(estimate: float, ci_low: float, ci_high: float, unseen: float)

Chao1 richness estimate: estimate total types, unseen = estimate − S_obs, and a log-normal 95% CI [ci_low, ci_high]. It is a lower bound — it only sees the rare-type tail.

Dispersion dataclass

Dispersion(item: str, frequency: int, range: int, parts: int, dp: float, dp_norm: float)

How evenly one item spreads over the documents of a corpus.

dp is Gries' deviation of proportions: 0 = the item is distributed exactly as the document sizes predict; values toward 1 = concentrated in few documents. dp_norm rescales by the attainable maximum (Lijffijt & Gries 2012) so corpora with different size profiles compare. range is the count of documents attesting the item (out of parts documents that have any items at all).

HeapsFit dataclass

HeapsFit(k: float, beta: float, r2: float)

Heaps' law fit V(N) = k·N^β: beta < 1 = sublinear vocabulary growth (normal for language). r2 is in log–log space.

KeynessRow dataclass

KeynessRow(item: str, target_count: int, target_total: int, reference_count: int, reference_total: int, log_likelihood: float, log_ratio: float, p_value: float)

One item's keyness in a target (sub)corpus against a reference.

log_likelihood is Dunning's G² (significance: is the imbalance more than chance?); p_value its χ²₁ tail. log_ratio is Hardie's log₂ ratio of relative frequencies (effect size: how big is the difference?) — positive = overused in the target, negative = underused; each whole point is a doubling. Zero counts are smoothed (default +0.5) for the ratio only, never for G².

ZipfMandelbrotFit dataclass

ZipfMandelbrotFit(s: float, beta: float, ks: float, r2_log: float, log_z: float)

Truncated Zipf–Mandelbrot MLE fit p(r) ∝ (r+β)^(−s) over the observed ranks. ks is the one-sample Kolmogorov–Smirnov statistic over token-share CDFs (smaller = closer; no p-value — the parameters are estimated from the same data). r2_log is log-space R²; log_z the log normalizer.

DivergenceRow dataclass

DivergenceRow(value: str, labels: list[str], la_count: int, lb_count: int, la_per_1000: float, lb_per_1000: float, log_ratio: float)

One shared phonetic value: its Linear A / Linear B counts and per-mille rates, and log_ratio = log₂ of the add-half-smoothed rate ratio (positive = over-used in Linear A relative to Linear B).

LaValueCount dataclass

LaValueCount(count: int = 0, labels: list[str] = list())

A Linear A phonetic value's token count and the AB sign labels behind it.

LaValueCounts dataclass

LaValueCounts(by_value: dict[str, LaValueCount], total_signs: int)

Linear A sign counts keyed by phonetic value, plus the total_signs denominator (every sign of every lexical word, valued or not).

LbFrequencies dataclass

LbFrequencies(version: str, generated: str, cite: str, counts: dict[str, int], total_signs: int, word_tokens: int, doc_count: int)

Linear B syllabogram frequencies from a DAMOS payload: counts maps a lowercase syllabogram value to its token count inside multi-sign words; total_signs / word_tokens / doc_count are the denominators and coverage, and version / generated / cite echo the dataset meta.

CommodityMetrology dataclass

CommodityMetrology(head: str, gloss: str, entries: int, fractional_pct: float, denominators: str, median: float, max: float)

A commodity's metrology over its counted lines: entries lines, the fractional_pct that carry a fraction, the denominators seen (space-joined), and the median / max line quantity.

Dossier dataclass

Dossier(word: str, entries: list[DossierEntry], entry_count: int, tablet_count: int, total_value: float, commodities: dict[str, int], sites: dict[str, int], co_listed: dict[str, int])

An account-head candidate and its evidence: the heading word, its entries (counted lines), distinct tablet_count, summed total_value (mixed units — a coarse proxy), and the commodities / sites / co_listed heads it travels with (insertion-ordered counts).

DossierEntry dataclass

DossierEntry(ins_id: str, site: str, line_tokens: list[str], value: float, commodity: str | None)

One ledger line an account head appears on: the source ins_id and site, the full line_tokens, the summed value after the head, and the line's commodity (or None).

DocumentTypeProfile dataclass

DocumentTypeProfile(type: str, count: int, share_pct: float, words_per_doc: float, numerals_pct: float, top_sites: list[str])

One physical-support type: document count, corpus share_pct, mean multi-sign words per document (words_per_doc), the share of documents carrying at least one numeral (numerals_pct — a proxy for accounting function, NOT a token fraction), and the top_sites (up to 2).

FractionRow dataclass

FractionRow(value: float, display: str, count: int, commodities: dict[str, int], example_ids: list[str])

One attested metrological fraction: its value, a display form (e.g. 3/4), corpus count, the commodities it co-occurs with on a line, and up to 12 example_ids.

MetrologyProfile dataclass

MetrologyProfile(fraction_rows: list[FractionRow], commodity_profiles: list[CommodityMetrology], numeral_tokens: int, fraction_tokens: int, integer_tokens: int, distinct_fraction_values: int)

The corpus metrology: the fraction_rows census, per-commodity commodity_profiles (commodities with ≥3 counted lines), and the numeral / fraction / integer token totals.

HandProfile dataclass

HandProfile(hand: str, doc_count: int, token_count: int, word_count: int, sites: dict[str, int] = dict(), periods: dict[str, int] = dict(), top_words: list[tuple[str, int]] = list())

A summary of one scribal hand's output within a corpus.

StructureCategory dataclass

StructureCategory(key: str, label: str, description: str)

A heuristic tablet-structure category (e.g. accounting/libation/list): key, label, description.

SignBigramModel dataclass

SignBigramModel(bigram: dict[str, dict[str, int]], context_total: dict[str, int], cont_types: dict[str, int], next_count: dict[str, int], total: int, vocab: int)

A trained first-order sign-bigram model.

bigram maps a context to (next → token count); contexts include ^ and nexts include $. context_total is the outgoing tokens per context, cont_types the distinct continuations per context (Witten-Bell's T(a)), next_count the next-symbol token counts (the backoff distribution), total the transition-token total, and vocab the number of distinct next symbols.

SurprisalStep dataclass

SurprisalStep(from_: str, to: str, bits: float)

One transition's surprisal in bits, showing where a word is improbable.

WordSurprisal dataclass

WordSurprisal(mean: float, steps: list[SurprisalStep])

A scored word: mean bits per transition (boundaries included — the headline number) and the per-transition steps.

MonteCarloResult dataclass

MonteCarloResult(observed: float, p_value: float, null_mean: float, null_low: float, null_high: float, level: float, n: int, null: str)

A Monte-Carlo permutation test of one structure statistic against a null.

observed is the statistic on the real word list. p_value is the one-sided (upper-tail) permutation p-value (1 + #{null ≥ observed}) / (1 + n) — the add-one form (Davison & Hinkley 1997, North et al. 2002), so it is never exactly 0 and is valid for finite n. null_mean and the [null_low, null_high] percentile band summarize the null distribution at level confidence; n is the number of null replicates and null names the generator. A small p_value means the observed value sits in the upper tail of what the null produces (more "structured" than chance under that null); a p_value near 0.5 means it is unremarkable.

McNemarResult dataclass

McNemarResult(b: int, c: int, statistic: float, p_value: float, method: str)

McNemar's paired test on two systems' binary correctness.

b = items system A got right and B wrong; c = B right, A wrong; these discordant counts are all the test uses (n = b + c). method is "exact" (two-sided binomial, used for small n) or "chi2" (continuity-corrected chi-square, 1 dof). statistic is the chi-square value for "chi2" and min(b, c) for "exact"; p_value is two-sided. A small p_value means the systems differ on the items where they disagree.

PairedBootstrapResult dataclass

PairedBootstrapResult(mean_difference: float, low: float, high: float, level: float, n_resamples: int, frac_a: float, frac_b: float)

A paired percentile bootstrap on per-item score differences A − B.

mean_difference is the observed mean of scores_a[i] − scores_b[i] (positive = A scores higher on average). [low, high] is the level percentile interval over the resampled mean differences: a band entirely above 0 favours A, entirely below favours B, straddling 0 is inconclusive. frac_a / frac_b are the fractions of resamples whose mean difference favoured A / B (resamples with a mean difference of exactly 0 count toward neither, so the two need not sum to 1).

SignEmbeddings dataclass

SignEmbeddings(vocab: tuple[str, ...], vectors: tuple[tuple[float, ...], ...], dim: int, window: int)

Dense distributional vectors for the signs of a script (EXPLORATORY).

vocab is the row order of vectors (one dim-vector per sign, already SVD-reduced and L2-normalized). window records the neighbour radius used. The vectors capture distributional similarity only; see the module docstring on why, on undeciphered or tiny corpora, neighbour lists are leads, not truth.

vector

vector(sign: str) -> tuple[float, ...]

The (L2-normalized) embedding of sign. Raises KeyError if unseen.

neighbours

neighbours(sign: str, k: int = 5) -> list[tuple[str, float]]

The k nearest signs to sign by cosine similarity, closest first.

sign itself is excluded. Vectors are unit-normalized, so cosine is a dot product; a returned score near 1 means near-identical context profiles, near 0 means unrelated. On a tiny corpus treat only the very top of the list as meaningful (EXPLORATORY: distributional, not phonetic, similarity).

HandGroup dataclass

HandGroup(hand: str, doc_count: int, doc_ids: list[str] = list(), sites: dict[str, int] = dict(), series: dict[str, int] = dict(), periods: dict[str, int] = dict())

One hand attribution's tablets, broken down by find-site and archival series.

hand is one distinct editorial attribution string (a hand number, possibly qualified). sites / series / periods are value -> tablet count maps (most-common first); doc_ids lists the tablets in corpus order. The series breakdown is populated only for a Linear B corpus (the designation convention it parses); a tablet whose series does not parse is counted in doc_count but not in series.

HandReport dataclass

HandReport(hand: str, doc_count: int, token_count: int, word_count: int, doc_ids: list[str] = list(), sites: dict[str, int] = dict(), series: dict[str, int] = dict(), periods: dict[str, int] = dict(), top_words: list[tuple[str, int]] = list())

One hand attribution's full descriptive profile.

hand is one editorial attribution string (a hand number, possibly qualified). Tablet / token / lexical-word totals, its tablets (doc_ids), its sites / series / periods breakdowns (value -> count, most-common first), and its top_words (the attribution's most frequent lexical words, (word, count)) computed with the standard corpus frequency machinery over the attribution's slice. The series breakdown is populated only for a Linear B corpus.

SeriesDossier dataclass

SeriesDossier(site: str, series: str, doc_count: int, doc_ids: list[str] = list(), hands: dict[str, int] = dict(), periods: dict[str, int] = dict(), token_count: int = 0, word_count: int = 0)

One archival grouping: the tablets sharing a find-site and a parsed series prefix.

A common Mycenological working unit, e.g. the Knossos Da sheep-tablets or the Pylos Aa personnel tablets. The grouping follows the tablet-designation convention: a well-established prefix names a recognised set, while a residual or unconventional prefix (a single-capital X fragment class, say) is grouped as parsed and is not asserted to be an attested archival set. hands / periods are value -> tablet count maps (most-common first) over the grouping's tablets; doc_ids lists them in corpus order; token_count / word_count sum the writing they carry.

Segmentation dataclass

Segmentation(word: str, units: tuple[str, ...], cuts: tuple[int, ...], pieces: tuple[str, ...])

One word's Harris segmentation.

word is the input string; units its unit sequence (signs for a hyphen-joined word, characters otherwise); cuts the inferred boundary offsets into units (each c means a boundary before units[c], so cuts are in 1 .. len(units)-1); and pieces the resulting unit-group morphs, each joined the same way the word was (- for signs, else concatenated). A single-unit or empty word has no cuts and one piece.

ClusterReport dataclass

ClusterReport(n_classes: int, n_signs: int, corpus_signs: int, corpus_bigrams: int, mutual_information: float, perplexity: float, mi_loss: float)

Quality of an induced sign-classing, for honest reporting.

n_classes is how many classes remain; n_signs the signs that were clustered. corpus_signs is the number of sign tokens the bigram model saw and corpus_bigrams the number of distinct adjacent pairs: both small on Aegean material, which is why the classes overfit (read them as leads). mutual_information is the average MI (bits) of the class-bigram model that the merges maximised, perplexity its 2^H branching factor over the class-bigram distribution. mi_loss is the total MI given up to reach this classing from one-sign-per-class (0 when no merge happened).

SignClasses dataclass

SignClasses(_class_of: dict[str, int], _members: list[list[str]], report: ClusterReport)

An induced distributional classing of a script's signs (EXPLORATORY).

class_of maps a sign label to its integer class id (-1 for a sign the corpus never attested). classes lists the members of each class. The report carries the quality numbers and the corpus size they rest on.

The classes reflect distribution, not phonetic value: two signs share a class because they occur in the same contexts, which is a candidate grammatical or phonotactic role, never a reading.

class_of

class_of(sign: str) -> int

The class id of sign (-1 if the corpus never attested it).

classes

classes() -> list[list[str]]

The classes as lists of sign labels, sign-sorted within each class.

Correspondence dataclass

Correspondence(source_sign: str, target_sign: str, score: float, rank: int)

One ranked cross-script sign-correspondence hypothesis (EXPLORATORY).

source_sign (in the source script) is aligned nearest to target_sign (in the target script) with cosine score in the rotated space, at 1-based rank among all target signs for this source sign. A high score means similar distributional position, not a reading or a decipherment; corroborate before use.

IdentityCheck dataclass

IdentityCheck(n: int, top1_recovery: float, top5_recovery: float, anchor_fraction: float)

How well aligning a space to itself recovers the identity (calibration).

top1_recovery / top5_recovery are the fractions of the evaluated signs whose own sign is the rank-1 / within-rank-5 correspondence after learning the rotation from an anchor subset. anchor_fraction records the split; n is the number of signs evaluated. With anchor_fraction = 1.0 (all signs anchored) recovery is near-perfect and is the sanity floor: on a clean, well-separated space it is 1.0, and on a real corpus it falls short of 1.0 only where distinct signs share an identical context vector (distributional twins that resolve to a tied sibling), for example 0.901 on the bundled Linear A. A lower fraction measures how well a partial seed generalizes. A floor down near chance would instead signal broken machinery, not a real null.

ProcrustesAlignment dataclass

ProcrustesAlignment(source_script: str, target_script: str, dim: int, n_anchors: int, anchor_fit: float, rotation: tuple[tuple[float, ...], ...], source_vocab: tuple[str, ...], source_vectors: tuple[tuple[float, ...], ...], target_vocab: tuple[str, ...], target_vectors: tuple[tuple[float, ...], ...])

A learned source→target embedding alignment and its hypothesis generator (EXPLORATORY).

rotation is the orthogonal dim × dim matrix mapping the (truncated, re-normalized) source space onto the target space; anchor_fit is the mean cosine of the anchor pairs after rotation (1 = perfect fit on the seeds). Use :meth:correspondences for one source sign or :meth:hypotheses for a ranked global list. These are geometry statistics offered as leads for a specialist, never readings.

correspondences

correspondences(source_sign: str, k: int = 5) -> list[Correspondence]

The k best target-sign hypotheses for one source sign, strongest first.

Ranks every target sign by cosine similarity to the rotated source vector. Raises KeyError if source_sign is not in the source vocabulary (EXPLORATORY: distributional geometry, not a reading).

hypotheses

hypotheses(*, k: int = 1, top: int | None = None) -> list[Correspondence]

A ranked global list of correspondence hypotheses across all source signs.

Takes each source sign's top k target matches and returns them sorted by score (strongest first). top truncates the returned list. Every entry is exploratory and the score is an alignment statistic, not a probability of correspondence.

RankReport dataclass

RankReport(n: int, n_targets: int, ranks: tuple[int, ...], top1: float, top5: float, median_rank: float, mean_rank: float, leave_one_out: bool)

Where known correspondence pairs land in the ranked hypotheses (calibration).

ranks is the 1-based rank of each known pair's true target sign among all n_targets target signs (lower is better; rank 1 = recovered). top1 / top5 are the fractions at rank ≤ 1 / ≤ 5; median_rank / mean_rank summarize the distribution. leave_one_out records whether each pair was held out of the anchors when it was scored (the honest generalization measure). Whatever this shows, weak or strong, is the honest strength of the cross-script signal (EXPLORATORY).

chance_median property

chance_median: float

The median rank pure chance would give ((n_targets + 1) / 2), for comparison.

account_lines

account_lines(document: Document) -> list[list[str]]

The document's physical lines as token-text lists.

balance_check

balance_check(document: Document) -> list[BalanceCheck]

Verify every total line on a document against its summed item lines.

Uses the script's total markers (Linear A's KU-RO/KU-RA and PO-TO-KU-RO, Linear B's TO-SO/TO-SA/TO-SO-DE).

checkable_accounts

checkable_accounts(corpus: Any, *, tolerance: float = 0.1) -> list[Document]

The intact, balancing accounts of a corpus (see :func:is_checkable_account).

is_checkable_account

is_checkable_account(document: Document, *, tolerance: float = 0.1) -> bool

Whether a document is an intact, balancing account — a clean drill / teaching candidate, and a useful "trust the arithmetic" corpus filter.

Intact = every token is securely read (ReadingStatus.CERTAIN, no bracketed restoration in the text), so no lacuna or damage muddies the sum. Balancing = at least one stated total (e.g. KU-RO) sits within max(1, tolerance × stated) of its summed items — the workbench's Scribe-School cutoff, lenient by default (10%) because Aegean metrology is imperfectly understood.

add_sequence

add_sequence(aln: list[AlnPos], seq: list[str], prior_n: int) -> list[AlnPos]

Add one word sequence to a growing alignment via Needleman–Wunsch at the word level (exact-token match rewarded, substitution columns allowed, gaps penalized). prior_n is how many sequences are already in the alignment.

align_phonetic

align_phonetic(a: str, b: str, w: PhoneticWeights = DEFAULT_WEIGHTS, cl: PhoneticClasses = DEFAULT_PHONETIC_CLASSES) -> list[AlignCell]

Run the weighted Levenshtein, then backtrace to emit a per-position alignment classifying each substitution as vowel / same-class / far.

align_sequences

align_sequences(seqs: list[list[str]]) -> list[AlnPos]

Progressive multiple alignment of word sequences (e.g. several inscriptions). Returns aligned positions, one column per input sequence.

chi_squared_2x2

chi_squared_2x2(joint: int, count_a: int, count_b: int, total: int) -> float

Yates-corrected chi-squared test statistic for the 2×2 table.

The continuity correction subtracts N/2 from |ad − bc| and clamps the corrected deviation at 0 (so near-independent pairs score ~0, not a small spurious positive). Returns 0 for degenerate tables.

chi_squared_p_value

chi_squared_p_value(x: float) -> float

p-value for chi-squared with 1 degree of freedom: P(X² ≥ x). In [0,1], and non-increasing in x.

fishers_exact

fishers_exact(joint: int, count_a: int, count_b: int, total: int) -> float

Fisher's exact test, two-sided, for the 2×2 table: the summed hypergeometric probability of all tables with the same marginals whose probability is ≤ the observed table's. More accurate than χ² for small expected counts but O(N) per pair. Returns 1 for a degenerate margin or an impossible table (one whose implied cell counts are negative).

log_likelihood_ratio_2x2

log_likelihood_ratio_2x2(joint: int, count_a: int, count_b: int, total: int) -> float

Log-likelihood ratio (G²) for the 2×2 table — Dunning (1993), the corpus-linguistics standard. G² = 2 · Σ O·ln(O/E) over the four cells; more robust than χ² for the sparse, low-count pairs of a small corpus. Returns 0 for degenerate tables; larger = stronger association.

pmi_interval

pmi_interval(joint: int, count_a: int, count_b: int, total: int) -> tuple[float, float]

Propagate a Wilson interval on the joint probability into a pointwise mutual information confidence interval (log₂ space), holding the marginals fixed. A zero lower joint clamps PMI low to a finite floor (−20).

sign_bigram_pmi

sign_bigram_pmi(joint: int, left_total: int, right_total: int, grand_total: int) -> float | None

Pointwise mutual information (bits) of an adjacent ordered sign pair a→b.

joint is the directed adjacency count of a→b; left_total the total outgoing adjacencies from a (a as the left/previous sign); right_total the total incoming adjacencies to b (b as the right/next sign); grand_total all adjacency-pair tokens. Returns log₂(joint·grand / (left·right)) — positive = the pair occurs more often than the two signs' slot frequencies predict, negative = less. Returns None (PMI undefined) when any input is zero, e.g. a never-attested pair. Directed: PMI(a→b) ≠ PMI(b→a) in general. Unsmoothed, so rare pairs read high.

sign_bigram_pmis

sign_bigram_pmis(words: Iterable[tuple[str, int]]) -> dict[tuple[str, str], float]

Directed sign-bigram PMI (bits) for every adjacent sign pair attested in a multi-sign word vocabulary.

words is an iterable of (word, count) pairs (hyphen-joined signs, a token frequency); adjacencies are token-weighted and subscript sign labels are folded (RA₂RA2). No boundary markers — interior adjacencies only. Returns {(a, b): pmi} over attested pairs (a never-attested pair is simply absent, its PMI being undefined).

wilson_interval

wilson_interval(k: int, n: int, z: float = 1.96) -> tuple[float, float]

Wilson score interval for a binomial proportion p̂ = k/n. Stays inside [0,1] with good coverage even at small/extreme p̂. z = 1.96 ≈ 95%.

ideogram_group_exclusivity

ideogram_group_exclusivity(lines: Iterable[Sequence[str]]) -> list[ExclusivityRow]

How exclusively each hyphenated word co-occurs (per line) with one ideogram group.

For every line, each ideogram occurrence is paired with each hyphenated word on the same line (multiplicity kept, not deduplicated). exclusivity of a word in a group is its in-group co-occurrence count divided by its total ideogram co-occurrences across all groups — so values for a fixed word sum to 1 across groups. A high exclusivity with a count ≥ 2 is the workbench's "strong domain evidence" signal. This is a descriptive share, not an association test (no smoothing, no expected-value model).

line_cooccurrence_pmi

line_cooccurrence_pmi(lines: Iterable[Sequence[str]], head: str, *, min_joint: int = 2) -> list[tuple[str, float]]

Line-level PMI of each transaction term against a commodity head, strongest first.

The event space is the set of physical lines: a term and the commodity are each counted once per line they appear on (deduplicated). For a term t with joint lines holding both, commodity_lines holding the commodity, and term_lines holding the term, out of total_lines lines::

PMI(t) = log₂( joint · total_lines / (commodity_lines · term_lines) )

A term is any token containing a hyphen; a commodity is any token that resolves to head via the catalog (ligatures / sex markers folded) or, for *NNN, the raw logogram. Only terms sharing at least min_joint lines with the commodity are returned (a hard cutoff, not smoothing); PMI may be negative. head may be a catalog head ("GRA") or an undeciphered "*NNN".

nearest

nearest(word: str, script: str, candidates: Iterable[str], candidate_script: str, *, top: int = 5, weights: PhoneticWeights = DEFAULT_WEIGHTS, classes: PhoneticClasses = DEFAULT_PHONETIC_CLASSES, fold_aspiration: bool = False) -> list[tuple[str, float]]

Rank candidates (in candidate_script) by phonetic distance to word (in script), nearest first; returns (candidate, distance) for the top closest (top=0 = all).

The intended use is decipherment-adjacent triage — e.g. which alphabetic Greek words sound closest to a Linear B form — where the ordering is the result and the absolute distances are secondary (see the module caution). Candidates that cannot be romanized are skipped.

phonetic_compare

phonetic_compare(word_a: str, script_a: str, word_b: str, script_b: str, *, weights: PhoneticWeights = DEFAULT_WEIGHTS, classes: PhoneticClasses = DEFAULT_PHONETIC_CLASSES, fold_aspiration: bool = False, overrides_a: dict[str, str] | None = None, overrides_b: dict[str, str] | None = None) -> PhoneticComparison

Compare two words across scripts by sound: romanize each, then run the weighted phonetic distance and the per-segment alignment.

The classic bridge is phonetic_compare("po-me", "linearb", "ποιμήν", "greek") — Linear B po-me against Greek poimēn 'shepherd'. Tune the metric with weights/classes (see distance) and meet defective syllabic spelling with fold_aspiration.

romanize_greek

romanize_greek(text: str, *, fold_aspiration: bool = False) -> str

Romanize alphabetic Greek to the Latin phoneme alphabet.

Strips accents, breathings, iota subscript, and diaeresis (NFD, then drop combining marks), lowercases, and maps each letter: θ→th, φ→ph, χ→kh, ξ→ks, ψ→ps, η→ē, ω→ō, and γ→n before a velar (γγ/γκ/γχ/γξ). Rough breathing (the /h/) is dropped with the other diacritics — the syllabaries don't write it either. fold_aspiration further maps θ/φ/χ → t/p/k for a fairer match against aspiration-blind syllabic spelling. Non-Greek letters pass through.

to_phonemes

to_phonemes(word: str, script: str, *, fold_aspiration: bool = False, overrides: dict[str, str] | None = None) -> str

Reduce word (in script) to a Latin phoneme string.

greek romanizes alphabetic text; lineara / linearb / cypriot map a hyphenated transliteration through their sign→sound tables (overrides tests alternative sign values). Raises ValueError for an unsupported script (e.g. undeciphered Cypro-Minoan).

build_phonetic_classes

build_phonetic_classes(scheme: PhoneticScheme = DEFAULT_PHONETIC_SCHEME) -> PhoneticClasses

Assemble concrete class tables from a scheme.

describe_phonetic_scheme

describe_phonetic_scheme(s: PhoneticScheme) -> str

One-line scheme description, for stamping into saved findings/reports so a match ranking stays reproducible.

extract_root

extract_root(word: str, overrides: dict[str, str] | None = None) -> str

The consonant skeleton of a word's phonetic form (vowels stripped), e.g. KU-ROkr. Exploratory root-cognate heuristic.

is_numeral_token

is_numeral_token(w: str) -> bool

True for digit / superscript / subscript / approx numeral tokens.

phonetic_distance

phonetic_distance(a: str, b: str, w: PhoneticWeights = DEFAULT_WEIGHTS, cl: PhoneticClasses = DEFAULT_PHONETIC_CLASSES) -> float

Weighted Levenshtein over phonetic strings, normalized to [0,1] by the longer length. Vowel↔vowel swaps cost 0.3, same-class consonants 0.5, everything else 1 (see PhoneticWeights).

reference_key

reference_key(raw_word: str, strip_notation: bool = True) -> str

Bare comparison key for a reference word: drop hyphens (so syllables concatenate like the Linear A side) and lowercase. With strip_notation, also remove pure-notation marks (reconstruction *, PIE laryngeal subscripts ₁₂₃, the labialization/aspiration modifiers ʰ ʷ, and the combining syllabic ring U+0325). So PIE *ǵʰésr̥ǵésr.

sequence_distance

sequence_distance(a: Sequence[object], b: Sequence[object]) -> int

Standard Levenshtein over arbitrary token sequences — compares whole inscriptions as ordered bags of words.

sequence_similarity

sequence_similarity(a: Sequence[object], b: Sequence[object]) -> float

Sequence distance normalized to a 0–1 similarity (1 = identical).

affix_edge_bias

affix_edge_bias(words: Iterable[tuple[str, int]], *, affix_len: int = 1, mode: str = 'suffix') -> list[EdgeBiasRow]

Edge-bias G² for every length-affix_len affix over a multi-sign word vocabulary, strongest (most edge-biased) first.

words is an iterable of (word, count) pairs — hyphen-joined signs and a token frequency; everything is token-weighted. mode is "suffix" (final slot) or "prefix" (initial slot). Words with no remaining stem (parts <= affix_len signs) are skipped. Interior occurrences are window matches of the affix that don't fall in the edge slot.

baayen_productivity

baayen_productivity(words: Iterable[tuple[str, int]], *, affix_len: int = 1, mode: str = 'suffix') -> list[Productivity]

Baayen's category-conditioned productivity P for every length-affix_len affix, most productive first.

P = (number of hapax-legomena types bearing the affix) / (total token frequency of all words bearing it). words is an iterable of (word, count) pairs; words with no remaining stem are skipped.

Caveat. On a corpus that is overwhelmingly hapax (most Linear A vocabulary) P runs high for nearly every affix, because almost every bearing type is itself a hapax. Read P as a relative ranking among affixes, never as an absolute productivity figure.

edge_bias_g2

edge_bias_g2(edge_count: int, edge_total: int, interior_count: int, interior_total: int) -> float

Signed Dunning G² of an affix's edge-slot rate vs its interior-window rate.

edge_count of edge_total edge slots hold the affix; interior_count of interior_total interior windows do. Positive = over-represented at the word edge (affix-like); negative = interior-leaning; an exact rate tie is positive. Returns 0 for an empty edge or interior population.

positional_bias

positional_bias(inscriptions: Iterable[Sequence[str]], *, min_count: int = 2) -> list[PositionalRow]

Positional-bias G² for every word attested min_count times, over the word-position events of a corpus.

inscriptions is an iterable of per-inscription word lists (any tokens without a hyphen are dropped, as in the workbench). Within an inscription a word's position is initial / medial / final among its hyphenated words; a lone hyphenated word counts in both edge slots (a deliberate workbench quirk that inflates the edge baselines). Baselines are summed over the whole vocabulary (hapaxes included); only words with count >= min_count are scored. Strongest positive bias first.

positional_bias_g2

positional_bias_g2(in_pos: int, slots: int, slot_total: int, grand_total: int) -> float

Signed Dunning G² of a word's share in its dominant slot vs the rest of the corpus's share in that slot.

in_pos of the word's slots positional events fall in its dominant slot; slot_total of grand_total corpus-wide events fall in that same slot. Positive = the word concentrates in its dominant slot more than the rest of the corpus does (a tie is positive); negative = it leans there least of all despite it being its commonest slot. Returns 0 when degenerate.

successor_variety

successor_variety(words: Iterable[str], *, min_prefix_signs: int = 2, min_variety: int = 3) -> SuccessorVariety

Harris (1955) successor variety over a multi-sign word vocabulary, scored as a ratio against the per-depth mean branching factor.

words is an iterable of distinct word types (token frequency is ignored — this is type-based). For each prefix of k signs, variety is the number of distinct signs that immediately follow it across the vocabulary; ratio divides that by the mean variety of all prefixes at depth k. Only prefixes of at least min_prefix_signs signs with variety at least min_variety are reported. A high ratio marks a candidate morpheme boundary — a heuristic, not a significance test.

find_morphological_clusters

find_morphological_clusters(words: Iterable[Mapping[str, object] | tuple[str, int]], min_suffix_productivity: int = 5, min_cluster_size: int = 2, max_suffix_len: int = 2) -> list[MorphCluster]

Cluster stems with their productive-suffix derivations.

words is an iterable of {"word": str, "count": int} mappings or (word, count) pairs (e.g. straight from Corpus.word_frequencies). A suffix is productive when it ends at least min_suffix_productivity distinct words; clusters smaller than min_cluster_size are dropped; suffixes are considered up to max_suffix_len signs long.

correspondence_analysis

correspondence_analysis(row_labels: Sequence[str], col_labels: Sequence[str], counts: Sequence[Sequence[float]]) -> CAResult | None

Correspondence analysis of a rows × columns contingency table.

Keeps the top two axes. Rows and columns land in the same plane: a row sits in the direction of the columns it over-uses; distance from the origin is deviation from the average profile. Returns None when there are fewer than 3 rows or columns, any zero-margin row/column (filter those first), or the table is independent (no inertia to plot).

label_propagation

label_propagation(nodes: Sequence[str], edges: Iterable[tuple[str, str, float]], *, seed: int = 7, max_iters: int = 50) -> dict[str, int]

Weighted label-propagation community detection (Raghavan et al. 2007).

edges is an iterable of (a, b, weight) tuples (undirected; self-loops and edges to unknown nodes are ignored). Deterministic via the seeded visit-order shuffle and label < current tie-break. Returns {node: community_id} with communities renumbered by descending size. Good for coloring a few hundred nodes — not a modularity method for large graphs.

upgma_with_bootstrap

upgma_with_bootstrap(items: Sequence[tuple[str, Mapping[str, float]]], *, iters: int = 100, seed: int = 42) -> DendroResult | None

Average-linkage UPGMA over labeled count vectors (cosine distance) with feature-resampled bootstrap support.

items is a sequence of (label, counts) pairs. Support for each node is the share of bootstrap replicates (features resampled with replacement, the standard move when features carry the signal) whose tree contains exactly the same member set. Returns None below 3 items or 2 features. Read support below ~0.5 as weak.

compile_sign_pattern

compile_sign_pattern(raw: str) -> CompiledSignPattern | None

Parse a wildcard sign pattern (KU-*-RO) into a CompiledSignPattern, or None if empty.

match_sign_pattern

match_sign_pattern(signs: list[str], pattern: CompiledSignPattern) -> bool

Match a word's sign sequence against a compiled pattern.

normalize_sign_label

normalize_sign_label(label: str) -> str

Fold subscript digits to ASCII (RA₂ → RA2).

word_matches_sign_pattern

word_matches_sign_pattern(word: str, raw: str) -> bool

Compile and match in one call. False for single-sign words / empty patterns.

build_cooccurrence_map

build_cooccurrence_map(documents: Iterable[Document]) -> dict[str, set[str]]

Map each multi-sign word to the set of multi-sign words it shares a document with.

build_word_index

build_word_index(documents: Iterable[Document]) -> dict[str, WordEntry]

Index every word token to the documents it appears in.

A word is any TokenKind.WORD token: a hyphen-joined multi-sign Aegean word (KU-RO), a single-sign Aegean word, or an alphabetic-Greek word (λόγος). Numerals, logograms, and punctuation are not words and are skipped.

default_value

default_value(field: str) -> Any

The neutral default value for a field, by kind.

eval_query

eval_query(filters: list[FilterRow], output: Output, documents: list[Document], word_index: dict[str, WordEntry], annotated_ids: set[str], cooccur_map: dict[str, set[str]]) -> QueryResults

Run a query (filters + output mode) over pre-built indices and return the result set in canonical shape.

For output="words" each (word, count) pair's count is the word's document frequency (the number of matching inscriptions it occurs in), not its token frequency; a word repeated within one inscription counts that inscription once.

inscription_matches

inscription_matches(doc: Document, filters: Iterable[FilterRow], annotated_ids: set[str]) -> bool

True if a document satisfies the inscription-scope filter rows (AND/OR/NOT-combined).

run_query

run_query(corpus: Any, filters: list[FilterRow], output: Output = 'inscriptions', annotated_ids: set[str] | None = None) -> QueryResults

Build the indices from a Corpus and evaluate filters.

Convenience over eval_query for the common whole-corpus case. The result carries the corpus's provenance and a filter summary, so it is citable via QueryResults.cite.

Raises TypeError if filters is not a sequence of FilterRow (e.g. a bare "field=value" string), and ValueError naming FIELDS if a row's field is not a known query field. This is the one shared guard behind Corpus.query; the CLI aegean query and MCP query_corpus wrappers validate the same way before reaching here.

summarize_filters

summarize_filters(filters: list[FilterRow]) -> str

One-line, human-readable label for a filter set.

word_matches

word_matches(word: str, filters: Iterable[FilterRow], cooccur_map: dict[str, set[str]]) -> bool

True if a word satisfies the word-scope filter rows (AND/OR/NOT-combined).

bootstrap_ci

bootstrap_ci(corpus: Any, statistic: Callable[[Sequence[Document]], float], *, n_resamples: int = 999, level: float = 0.95, seed: int = 0) -> BootstrapCI

Percentile bootstrap CI for statistic(documents).

Documents are the resampling unit (drawn with replacement, original size), the right grain for corpus questions where tokens within a document are not independent (Efron & Tibshirani 1993). The seed makes the interval reproducible by default — vary it to see Monte-Carlo wobble. The band quantifies sampling variability given these documents; it cannot speak to what was never excavated.

mean_doc_words = lambda docs: sum( ... len([t for t in d.tokens if t.kind is TokenKind.WORD]) for d in docs ... ) / len(docs) bootstrap_ci(corpus, mean_doc_words) # doctest: +SKIP BootstrapCI(estimate=7.1, low=6.4, high=7.9, level=0.95, n_resamples=999)

bootstrap_counts_ci

bootstrap_counts_ci(counts: Sequence[float], stat: Callable[[list[int]], float], *, iters: int = 200, seed: int = 1, alpha: float = 0.05) -> tuple[float, float]

Percentile bootstrap CI for any stat of a count vector.

Resamples N tokens from the empirical distribution (multinomial with p̂ᵢ = cᵢ/N) and hands the resampled count vector — same length, same category order — to stat; returns the [α/2, 1−α/2] percentile interval. Deterministic via seed (the same mulberry32 stream as the workbench). The resample treats tokens as independent draws, which corpus tokens are not (words repeat whole), so the interval is a lower bound on the real uncertainty.

chao1

chao1(s_obs: float, f1: float, f2: float) -> Chao1Result

Chao1 lower-bound vocabulary size from observed types and the hapax / dis legomena counts.

Ŝ = S_obs + F₁²/(2F₂) when F₂ > 0; the bias-corrected Ŝ = S_obs + F₁(F₁−1)/2 when F₂ = 0. Uses Chao's (1987) variance and the standard log-normal CI on the unseen-type count.

dispersion

dispersion(corpus: Any, item: str, *, kind: str = 'words') -> Dispersion

Gries' DP for one item over the documents of corpus.

DP = ½ · Σᵢ |vᵢ − sᵢ| where sᵢ is document i's share of the corpus (in items of this kind) and vᵢ the share of the item's occurrences falling in document i (Gries 2008). dp_norm divides by the attainable maximum 1 − min(sᵢ) (Lijffijt & Gries 2012). Raises ValueError if the item never occurs.

dispersions

dispersions(corpus: Any, *, kind: str = 'words', min_frequency: int = 2, top: int = 0) -> list[Dispersion]

DP for every item with frequency ≥ min_frequency, most evenly dispersed first (ties: higher frequency first). top truncates (0 = all).

Reading the ranking: a frequent item with low dp_norm is corpus-wide vocabulary; a frequent item with high dp_norm lives in few documents (a formulaic or genre/site-bound term) — on Aegean material often the more interesting case.

fit_heaps

fit_heaps(points: Sequence[tuple[float, float]]) -> HeapsFit | None

Fit Heaps' law V(N) = k·N^β by least squares in log–log space over a vocabulary-growth curve of (tokens, types) points. Needs at least five points with tokens ≥ 1 and types ≥ 1; returns None otherwise.

fit_zipf_mandelbrot_mle

fit_zipf_mandelbrot_mle(freqs: Sequence[float]) -> ZipfMandelbrotFit | None

Fit a truncated Zipf–Mandelbrot rank–frequency model by maximizing the multinomial log-likelihood over a coarse (s, β) grid refined twice around the optimum. freqs are rank-ordered frequencies (rank 1 first); returns None for fewer than five ranks.

keyness

keyness(target: Any, reference: Any, *, kind: str = 'words', min_target: int = 2, smoothing: float = 0.5) -> list[KeynessRow]

Key items of target against reference, strongest first.

For each item the 2×2 table is (count in target, rest of target, count in reference, rest of reference); G² follows Rayson & Garside (2000) and the log-ratio Hardie (2014). Items need target_count ≥ min_target or to be similarly frequent in the reference (so marked under-use surfaces too). Sorted by G² descending — filter log_ratio > 0 for the target's own vocabulary, < 0 for what it conspicuously lacks.

The two corpora must be distinct texts (a subset vs its complement is the classic design: keyness(c.filter(site="Pylos"), rest)).

mattr

mattr(tokens: Sequence[str], window: int = 100) -> float | None

MATTR — moving-average type-token ratio (Covington & McFall 2010).

The mean TTR over every sliding window of window tokens. Unlike raw TTR it does not shrink mechanically as the stream grows, so differently sized slices compare. Returns None when the stream is shorter than one window.

miller_madow_entropy

miller_madow_entropy(counts: Sequence[float]) -> float

Miller–Madow bias-corrected entropy in bits.

The plug-in estimator systematically underestimates entropy in small samples (unseen categories contribute nothing); the first-order correction adds (K − 1) / (2·N·ln 2) bits, K = observed categories, N = sample size. Still an underestimate when many categories are unseen — the honest situation for sign bigrams in a few-thousand-token corpus.

mulberry32

mulberry32(seed: int) -> Callable[[], float]

A tiny, fast, seeded 32-bit PRNG (mulberry32).

Returns a zero-argument callable yielding floats in [0, 1). Every resample / permutation here runs from an explicit seed so a cited number is reproducible on re-run; the 32-bit arithmetic reproduces the workbench's JavaScript implementation bit-for-bit, so both tools agree given one seed.

shannon_entropy

shannon_entropy(counts: Sequence[float]) -> float

Shannon entropy in bits of a count vector (maximum-likelihood plug-in).

Zero counts contribute nothing; returns 0 for an empty or single-category vector.

spearman_rho

spearman_rho(xs: Sequence[float], ys: Sequence[float]) -> float

Spearman rank correlation, with average ranks for ties.

One number for "do two paired series rank their items the same way?" — e.g. do two scripts use a shared signary in the same proportions? Returns 0 for fewer than 3 points, mismatched lengths, or a constant series.

build_lb_divergence

build_lb_divergence(la: LaValueCounts, lb: LbFrequencies) -> list[DivergenceRow]

Join the two sign-value frequency tables on their shared attested values, most divergent (largest |log ratio|) first.

Rates are smoothed add-half before the log₂ ratio. Only values attested in both scripts are returned. Empty if either side has no signs. The join itself assumes the conventional sign values — that is the hypothesis, not a result.

linear_a_sign_value_counts

linear_a_sign_value_counts(words: Sequence[tuple[str, int]]) -> LaValueCounts

Token-weighted Linear A sign counts over the lexical multi-sign vocabulary, keyed by each sign's conventional phonetic value (lowercase — the Linear B join key).

words is an iterable of (word, count) pairs; non-lexical words (see :func:is_lexical_word) are skipped. Signs without a conventional value still count toward total_signs so the two corpora share a denominator definition.

parse_damos_frequencies

parse_damos_frequencies(payload: dict[str, object]) -> LbFrequencies

Count Linear B syllabogram frequencies in a damos-corpus.json payload ({_meta, documents: [{content}]}).

Counting basis (mirrored on the Linear A side): signs inside multi-sign word tokens only. Logograms, numerals, single-sign words, and pieces that don't parse as syllabogram chains are skipped. Damaged-sign dots and editorial brackets are stripped rather than excluded — DAMOS marks uncertainty densely, and dropping every dotted sign would bias against worn tablets.

account_dossiers

account_dossiers(corpus: Any) -> list[Dossier]

Gather, per account-head candidate, the counted ledger lines it heads.

A line's head is its first lexical (syllabic) word; the script's total / grand-total / deficit markers (KU-RO, PO-TO-KU-RO, KI-RO; Linear B's to-so / to-sa / to-so-de, o-pe-ro; matched case-insensitively) are excluded — they are accounting operators, not holders. A line counts only if a numeral follows the head; its value sums the tokens after the head and its commodity is the first commodity logogram after it. Sorted by entry count desc, head asc.

"Account holder" is a working hypothesis — a head may be a person, place, institution, or transaction term; this just assembles the evidence.

document_type_profile

document_type_profile(corpus: Any) -> list[DocumentTypeProfile]

Profile a corpus by physical document type, most common type first.

words_per_doc counts multi-sign (hyphenated) word tokens, the writing an object carries. numerals_pct is the percentage of documents with at least one numeral (the separator dot is never a numeral).

metrology_profile

metrology_profile(corpus: Any, *, min_entries: int = 3) -> MetrologyProfile

Build the corpus metrology profile (fraction census + per-commodity counted-vs-measured), iterating physical lines.

Each line is credited to its first commodity logogram; the line's numerals sum to one entry for that commodity. A commodity needs min_entries (3) counted lines to get a profile. Linear A metrology is contested, so read the denominators and fractional shares as exploratory description.

hand_keyness

hand_keyness(corpus: Any, hand: str, *, kind: str = 'words', min_target: int = 2) -> list[KeynessRow]

Words (or signs) characteristic of one scribal hand versus all other hands.

Splits corpus into the hand's documents (target) and every other document (reference) and runs the standard log-likelihood keyness. Raises ValueError if no document is attributed to hand.

scribal_hands

scribal_hands(corpus: Any, *, top_n: int = 8, min_docs: int = 1) -> list[HandProfile]

Profile every scribal hand in corpus (documents grouped by meta.scribe).

Returns one HandProfile per hand with at least min_docs tablets — tablet count, token and lexical-word totals, the sites and chronologies the hand is attested at, and the hand's top_n most frequent words — sorted by tablet count (then hand id). Documents with no recorded hand are skipped.

classify_corpus

classify_corpus(corpus: object) -> dict[str, list[str]]

Classify every document in a corpus, returning {category_key: [doc_id, ...]} with every category present (empty lists included) and documents in corpus order.

classify_structure

classify_structure(document: Document) -> str

The heuristic category key for one inscription, from its content shape.

Mirrors the workbench precedence exactly: a KU-RO total marker (or numerals with several multi-sign words) ⇒ accounting; otherwise a libation formula ⇒ libation; otherwise many separators and no numerals ⇒ list; otherwise an extended hyphenated text with no numerals ⇒ text; else other.

train_sign_bigram_model

train_sign_bigram_model(words: Iterable[tuple[str, int]]) -> SignBigramModel

Train the bigram model on a multi-sign vocabulary, token-weighted.

words is an iterable of (word, count) pairs; single-sign words (no -) are skipped. A transition in a 20× word counts 20 times — the model describes what scribes actually wrote, not the type list.

word_surprisal

word_surprisal(model: SignBigramModel, word: str, self_count: int = 0) -> WordSurprisal

Score word against the model.

self_count is the word's own corpus token count: its contribution is subtracted from every count before computing probabilities (leave-one-out), per occurrence — a word like a-b-a-b carries a→b twice, so its two self-occurrences are both removed there. Pass 0 to score a hypothetical word not in the corpus. Probabilities use add-one-smoothed Witten-Bell backoff, so unseen symbols keep nonzero mass and bits stay finite and non-negative.

length_reshuffle_null

length_reshuffle_null(words: Sequence[str], rand: Callable[[], float]) -> list[str]

One length-stratified whole-word reshuffle replicate of words.

Groups the words by sign-length and shuffles each length stratum among its own members, then reassembles in the original length-slot order. Preserves every word intact (its exact internal sign sequence) and the per-length word counts; destroys the position of a word within its length stratum. rand is a zero-argument [0, 1) source.

monte_carlo_p

monte_carlo_p(observed: float, statistic: Statistic, words: Sequence[str], *, null: str = 'within_word', n: int = 999, seed: int = 0, level: float = 0.95) -> MonteCarloResult

One-sided permutation p-value of a structure statistic against a null.

Generates n null word lists with the chosen null generator (each seeded from mulberry32(seed)), scores statistic on each, and compares the observed value to that null distribution.

The p-value is the add-one upper-tail estimate (1 + #{null ≥ observed}) / (1 + n): it is one-sided, treats larger statistic values as more "structured", and is bounded away from 0 so a finite n never reports an impossibly exact result. Pass observed = statistic(words) for the usual case; it is a separate argument only so a precomputed or differently measured observed value can be tested against the same null.

null is "within_word" (within-word sign permutation: holds word lengths and the unigram-sign counts, breaks sign order) or "reshuffle" (length-stratified whole-word reshuffle: holds each word intact, breaks word position). See the module docstring for exactly what each preserves.

Exploratory. A small p-value means the statistic exceeds what this null produces; it is evidence of structure relative to the null, not a decipherment or a linguistic claim. Always report the null name, seed, and n with the number.

within_word_null

within_word_null(words: Sequence[str], rand: Callable[[], float]) -> list[str]

One within-word sign-permutation null replicate of words.

Pools every sign across the whole corpus, shuffles the pool, and redeals it into words of the original sign-lengths (in order). Preserves the per-word length sequence and the corpus-wide unigram-sign multiset (each sign's total count); destroys sign order and within-word co-occurrence. rand is a zero-argument [0, 1) source (e.g. from :func:aegean.analysis.stats.mulberry32).

mcnemar

mcnemar(correct_a: Sequence[bool], correct_b: Sequence[bool], *, exact_threshold: int = 25) -> McNemarResult

McNemar's test for whether two systems' per-item correctness differs.

correct_a / correct_b are equal-length, gold-aligned booleans (item i right or wrong for each system). Only discordant items contribute: b = A-right/B-wrong, c = A-wrong/B-right. When b + c ≤ exact_threshold the exact two-sided binomial is used (reliable for the small discordant counts typical of a single fold); otherwise the continuity-corrected chi-square (|b−c|−1)² / (b+c) with its 1-dof tail. No discordant items (b + c = 0) is an undefined test and returns p_value = 1.0. Raises ValueError on a length mismatch.

r = mcnemar([True, True, False], [True, False, False]) r.b, r.c, r.method (1, 0, 'exact')

paired_bootstrap

paired_bootstrap(scores_a: Sequence[float], scores_b: Sequence[float], *, n_resamples: int = 999, level: float = 0.95, seed: int = 0) -> PairedBootstrapResult

Paired bootstrap CI on the mean per-item score difference A − B.

scores_a / scores_b are equal-length, gold-aligned per-item scores (0/1 accuracy, a per-sentence metric, any number). The per-item differences are resampled with replacement n_resamples times; the returned interval is the level percentile band on the mean difference, with the fraction of resamples favouring each system. Reproducible by seed (the shared mulberry32 stream); vary it to gauge Monte-Carlo wobble. The band quantifies sampling variability given these items only. Raises ValueError on a length mismatch, fewer than two items, or a level outside (0, 1).

r = paired_bootstrap([1, 1, 1, 1], [0, 0, 0, 0], seed=1) r.mean_difference, r.low > 0 (1.0, True)

sign_embeddings

sign_embeddings(corpus: Any, *, dim: int = 50, window: int = 1) -> SignEmbeddings

Learn a distributional vector per sign from a script corpus (EXPLORATORY).

Builds a (sign × context) co-occurrence table from sign adjacency within words (left/right neighbours up to window positions, plus word-initial and word-final slot columns), reweights it with PPMI, and reduces each row to at most dim dimensions with a deterministic truncated SVD; the returned vectors are L2-normalized so :meth:SignEmbeddings.neighbours reads as cosine similarity.

Parameters

corpus: A Corpus, QueryResults, or iterable of Document. Only multi-sign WORD tokens contribute (single-sign words have no internal adjacency). dim: The maximum embedding dimensionality. The effective dimension is capped at the context-vocabulary size and at the number of non-degenerate singular values, so small corpora yield shorter vectors. window: Neighbour radius in signs (1 = immediate neighbours only).

Returns a :class:SignEmbeddings. Raises ValueError if the corpus has no multi-sign words or dim/window is not positive.

Caveat (EXPLORATORY). The vectors encode distributional context, not phonetic or semantic value; on undeciphered scripts they are a structure-surfacing aid, not a decipherment, and on the small Aegean corpora the geometry is noisy. Trust only the strongest neighbours and corroborate before reading anything into them.

by_hand

by_hand(corpus: Any, *, min_docs: int = 1) -> list[HandGroup]

Group a corpus's documents by hand attribution, with a site / series breakdown.

Returns one HandGroup per distinct attribution string (meta.scribe) attested on at least min_docs documents, sorted by tablet count desc, then attribution string. A group key is one attribution string (a hand number, possibly qualified with a certainty mark or sub-hand tag), so the number of groups counts distinct attribution strings, not necessarily distinct scribes. Documents with no recorded hand are skipped.

The series breakdown is parsed from each document's designation (see :func:series_of) and is filled only for a Linear B corpus, where that convention applies; on other scripts it is left empty. The attribution is the edition's, passed through unchanged; this just counts the tablets, sites, and series each recorded attribution carries.

dossiers

dossiers(corpus: Any, *, min_docs: int = 1) -> list[SeriesDossier]

Group Linear B documents into archival dossiers by shared find-site and series.

A dossier here is a (site, series) grouping: the tablets found at one site and sharing one series prefix (see :func:series_of). This is the conservative reading of the metadata that exists, the find-site (meta.site) and the series parsed from the designation. It does not attempt joins, hand-based sets, or physical-fit reconstructions the recorded fields cannot support.

The series parse is a Linear B designation convention, so this raises ValueError on a non-Linear-B corpus (corpus.script_id != "linearb") rather than read a spurious prefix out of an unrelated id scheme. The grouping follows the designation convention: a residual or unconventional prefix is grouped as parsed, not asserted to be an attested archival set.

Returns one SeriesDossier per grouping with at least min_docs documents, sorted by tablet count desc, then site, then series. Documents whose series does not parse are left out (they belong to no series).

hand_profile

hand_profile(corpus: Any, hand: str, *, top_n: int = 15) -> HandReport

Profile one hand attribution hand: its tablets, sites, series, and vocabulary.

hand is an editorial attribution string (a hand number, possibly qualified). Builds the sub-corpus of the tablets carrying that attribution and reports its counts, its site / series / chronology breakdowns, and its top_n most frequent lexical words (via Corpus.word_frequencies, the standard machinery). The series breakdown is filled only for a Linear B corpus. Raises ValueError if no document carries the attribution.

The vocabulary is descriptive (the words this attribution happened to write most), not a claim about a scribe's remit; for what is distinctive of one attribution versus the others use :func:aegean.analysis.hand_keyness.

series_of

series_of(doc: Any) -> str | None

The archival series of a Linear B tablet, parsed from its designation, or None.

Defined for Linear B tablet designations (the Bennett/Olivier sigla). Accepts a Document (uses doc.id) or a plain id string. The series is the alphabetic run of the second whitespace-delimited field, the classification prefix after the site code: series_of("KN Fp(1) 1 (138)") == "Fp", series_of("PY Ta 641") == "Ta". A designation with no such field (only a site code and a number, e.g. "SID 1 (-)") returns None.

This is a pure parser: it reads whatever second field an id carries and does not check the script, so a caller that passes an unrelated id scheme gets that scheme's second field back. It classifies nothing anew, and a residual or unconventional prefix is returned as parsed, not asserted to be an attested series. For the script-guarded grouping use :func:dossiers.

brainerd_robinson

brainerd_robinson(matrix: Sequence[Sequence[float]]) -> list[list[float]]

The Brainerd-Robinson similarity matrix of an abundance table.

Each row of matrix (an assemblage's type counts) is first rescaled to sum to 100, then the similarity of two rows p and q is BR = 200 - Σ_k |p_k - q_k|: 200 for identical proportional profiles, 0 for no shared types. The result is a symmetric n × n matrix with 200 on the diagonal.

Raises ValueError on an empty or ragged matrix or negative counts. This is a proportional-abundance similarity; on undeciphered material the "types" are signs or word forms, so read it as compositional similarity, not chronology (EXPLORATORY).

chronology

chronology(corpus: Any) -> Chronology

Parse every document's meta.period into a numeric year span (EXPLORATORY).

Reuses :func:aegean.viz.parse_period (BCE negative, CE positive) on each document's free-text date, returning a :class:Chronology that pairs the parsed spans with an honest count of what could not be read.

Parameters

corpus: A Corpus, QueryResults, or iterable of Document.

Caveat (EXPLORATORY). parse_period is a best-effort reader of origDate-style strings, not a dating authority; a returned span is a coarse century-level bin, and the unparsed fraction is reported precisely because a corpus's dates are often imprecise or unreadable. This is input for a chronological hypothesis, not a date.

seriate

seriate(matrix_or_corpus: Any, *, labels: Sequence[str] | None = None, max_iter: int = 200) -> SeriationResult

Seriate an abundance table (or a corpus) by Brainerd-Robinson similarity (EXPLORATORY).

Builds the Brainerd-Robinson similarity matrix (see :func:brainerd_robinson) and orders the rows so that compositionally similar assemblages sit next to each other, using a deterministic spectral ordering (the Fiedler vector of the similarity's Laplacian). The ordering does not depend on the order the rows were supplied in.

Parameters

matrix_or_corpus: Either a 2-D abundance table (rows = assemblages, columns = type counts) or a Corpus / Document iterable, in which case a document × sign-type count matrix is built automatically (rows are the sign-bearing documents, columns the signs that occur). labels: Optional row labels for a matrix input (must match the row count). Ignored for a corpus input, where document ids are used. max_iter: Cap on iterations for the large-matrix power-iteration fallback (the dense eigensolver used for ordinary tables ignores it). Must be positive.

Returns a :class:SeriationResult. Raises ValueError on an empty/ragged matrix, a labels-length mismatch, or a corpus with no sign-bearing documents.

Caveat (EXPLORATORY). The ordering is a hypothesis about relative sequence from compositional similarity, with no direction and no calendar anchor; on undeciphered scripts a "type" is a sign, so the axis may track scribal or graphotactic drift, not time. Corroborate against external evidence before reading chronology into it.

variant_groups

variant_groups(script_id: str) -> AllographReport

Group a script's signs into catalogued variant sets, from the inventory's own data.

Reads get_script(script_id).sign_inventory and groups signs sharing a base value by the label conventions the inventory uses: numbered homophone series (Linear A / Linear B) and Cypro-Minoan catalogue-letter suffixes. Only groups with 2+ members are returned; ligature/compound labels are listed under composite_signs, not grouped.

Returns an :class:AllographReport. Raises KeyError for an unregistered script id.

Caveat (EXPLORATORY). These groupings come entirely from the transliteration and catalogue naming (a shared base romanization or catalogue number), which is not the same as palaeographic allography, the same sign drawn differently by different hands. That letter-form variation is not present in the bundled inventories and is not claimed. Use the report as a catalogue-structure starting point for a specialist, not as a palaeographic result.

candidate_morphs

candidate_morphs(words: Iterable[str], *, min_count: int = 2) -> list[tuple[str, int]]

Recurring word-final candidate morphs, by the count of distinct words bearing them.

Each word is segmented (see :func:segment); a word's final piece, i.e. the unit-group after its last cut, is taken as its candidate suffix. The count for a morph is the number of distinct word types ending in it (so a single word repeated cannot inflate a morph), and only morphs reaching min_count distinct words and at least one cut (the word actually had a stem before the morph) are returned. Sorted by count descending, then by the morph string.

EXPLORATORY: a high count means the vocabulary reuses that final string after a variety-peak boundary, a candidate productive suffix, never a confirmed morpheme or a meaning. Read the ranking as leads to inspect.

segment

segment(words: Iterable[str]) -> list[Segmentation]

Harris bidirectional segmentation for every word, input order preserved.

Forward and backward unit tries are built once over the de-duplicated word list; each word is then cut at the local peaks of its successor- and predecessor-variety curves (the two boundary sets are unioned). Duplicate input words yield identical :class:Segmentation records. Single-unit and empty words return uncut, with one piece (or none).

EXPLORATORY: the cuts are distributional hypotheses. See the module docstring; on hapax-heavy undeciphered corpora the variety signal is thin.

induce_classes

induce_classes(corpus: Any, *, n_classes: int) -> SignClasses

Induce n_classes distributional sign classes by Brown clustering.

EXPLORATORY. Builds the adjacent sign-bigram model of corpus (a Corpus, QueryResults, or document list; numerals/separators/punctuation skipped, with each token bounded so bigrams never cross a token edge), seeds one class per sign, then greedily merges the class pair whose merge gives up the least average mutual information of the class-bigram model, until n_classes classes remain (Brown et al. 1992). Ties in MI loss are broken by the lower pre-sorted class index, jittered with the shared mulberry32 PRNG, so a run is reproducible.

The result is a :class:SignClasses: signs in one class occur in the same contexts, which is a candidate shared role (a vowel/consonant or open/closed split, a positional pattern), not a phonetic reading. On the small Aegean corpora (especially undeciphered Cypro-Minoan and Linear A) most sign bigrams are seen once or never, so the classing overfits: the attached report carries the corpus size so the classes are read as leads, not facts.

Raises ValueError for n_classes < 1 or a corpus with no multi-sign sequences to learn from.

align_embeddings

align_embeddings(source: SignEmbeddings, target: SignEmbeddings, anchors: Sequence[tuple[str, str]], *, source_script: str = 'source', target_script: str = 'target') -> ProcrustesAlignment

Align two sign-embedding spaces by orthogonal Procrustes on anchor pairs (EXPLORATORY).

Learns the orthogonal rotation mapping source onto target from the anchors (a seed dictionary of (source_sign, target_sign) correspondences), truncating both spaces to their common leading dimensionality. The returned :class:ProcrustesAlignment generates ranked correspondence hypotheses.

Raises ValueError if either space is empty or no anchor pair is present in both vocabularies.

Caveat (EXPLORATORY). Alignment between an undeciphered and a deciphered script is not decipherment; a score reflects distributional position in tiny corpora. Calibrate with :func:recover_identity and :func:rank_known_pairs before trusting any lead.

align_scripts

align_scripts(source_corpus: Any, target_corpus: Any, *, source_script: str = 'source', target_script: str = 'target', dim: int = 50, window: int = 1, anchors: Sequence[tuple[str, str]] | None = None) -> ProcrustesAlignment

Build sign embeddings for two corpora and align them by Procrustes (EXPLORATORY).

Convenience wrapper: learns :func:aegean.analysis.embeddings.sign_embeddings for each corpus, then :func:align_embeddings. When anchors is None the shared-value signs (:func:shared_label_anchors) are used as the seed dictionary.

Caveat (EXPLORATORY). See :func:align_embeddings. Cross-script alignment produces hypotheses for a human expert, not readings.

rank_known_pairs

rank_known_pairs(source: SignEmbeddings, target: SignEmbeddings, pairs: Sequence[tuple[str, str]], *, leave_one_out: bool = True) -> RankReport

Report where known correspondence pairs rank in the aligned hypotheses (calibration).

For each known (source_sign, target_sign) pair, learns the alignment and finds the rank of target_sign in the source sign's ranked correspondence list. With leave_one_out=True (default) the pair being scored is excluded from the anchors, so the rank measures genuine generalization rather than memorized anchors. With leave_one_out=False a single alignment is learned from all pairs and reused.

Only pairs whose both signs are in the two vocabularies are scored. Raises ValueError if none qualify. Whatever rank distribution results is the honest measure of how much the geometry recovers the known mapping, weak or strong (EXPLORATORY).

recover_identity

recover_identity(emb: SignEmbeddings, *, anchor_fraction: float = 0.5, seed: int = 1) -> IdentityCheck

Align an embedding to itself from an anchor subset and measure identity recovery.

Splits the vocabulary into an anchor set (anchor_fraction) and an evaluation set (the remainder, or the whole vocabulary when anchor_fraction = 1.0) using a seeded deterministic shuffle, learns the source→source rotation from the anchors, and reports how often an evaluated sign's top-ranked correspondence is itself. Aligning a space to itself should recover almost every sign (it falls short only on distributional twins, distinct signs with an identical context vector), so a high but sub-perfect score is the expected calibration sanity floor; a floor near chance would instead signal broken machinery rather than a genuine null.

Raises ValueError for an out-of-range anchor_fraction or an empty vocabulary.

shared_label_anchors

shared_label_anchors(source: SignEmbeddings, target: SignEmbeddings) -> list[tuple[str, str]]

Anchor pairs where a source and target sign share a transliteration value.

Matches signs by folded label (subscripts to ASCII, upper-cased), so a source RA₂ pairs with a target RA2. Returns (source_label, target_label) pairs using each vocabulary's own spelling, in source-label order. For Linear A vs Linear B these are the AB chart-shared signs, a partial ground truth for calibration.