ek
ek – a framework for building Knowledge Evaluation systems.
ek evaluates the outputs of information-extraction systems. OCR is treated as
the noisiest special case of a general problem, so the core is source-agnostic
(PDF, DOCX, tables, DB responses, LLM extractors) and the OCR specifics live in
ek.ocr.
Two facades cover the two halves of evaluation, both operating on the same typed schema:
score()/evaluate()– reference-based (offline): compare against a gold answer, one item or a whole corpus, the metric chosen by output type.estimate_quality()– reference-free (online): gather signals, calibrate, validate, and decide accept/flag/block with no gold answer.
Everything swappable is a typing.Protocol strategy resolved from
ek.registry and injected as a keyword-only argument with a smart default –
so the simple path Just Works and every layer stays replaceable (open-closed).
- Quickstart:
>>> import ek >>> round(ek.score("hello wrld", "hello world").value, 3) # CER by default 0.091 >>> ek.evaluate([("ct", "cat"), ("dg", "dog")], metric="cer").n 2
The two-layer data model (GraphGrammar for the schema + cost weights,
AnnotatedExtraction for per-extraction verification metadata) is the SSOT
every component plugs into – see ek.base. Persistence is local-file dol
stores under ~/.local/share/ek/ – see ek.stores.
- class ek.AgreementSignal(cost_tier: int = 3, use_confidence: bool = True, conf_weight: float = 0.5, null_conf: float = 0.7, tokenize: Callable[[Any], List[Tuple[str | None, float]]] | None = None, max_tokens: int | None = 5000)[source]
ROVER multi-engine agreement as a reference-free
Signal.Cost tier 3 (
Nengine runs): try the deterministic verifier layer and any free intrinsic confidence first. Called on a collection of hypotheses, it runsrover()and returns the mean per-position agreement as the raw signal – uncalibrated, like every signal, so it must pass through aCalibratorbefore any gate reads it.Example
>>> sig = AgreementSignal() >>> round(sig(["the cat sat", "the cat sit", "the bat sat"]), 3) 0.778
- class ek.AnlsMetric(*, threshold: float = 0.5)[source]
ANLS / ANLS* (nested-JSON) similarity as a
Metric.Handles a bare string (classic ANLS) or an arbitrarily nested dict/list/tuple/
Nonestructure (ANLS*); the backend dispatches on type.- Parameters:
threshold – ANLS zeroes a per-leaf similarity below this value before averaging (the classic ANLS 0.5 cut tolerates minor OCR/spelling noise).
anls_starexposes no parameter for this – it reads a class constantANLSTree.THRESHOLD(default0.5) – so the metric applies the threshold by setting that constant around the call and restoring it. Caveat: this mutates a process-global, so it is not thread-safe; concurrentAnlsMetriccalls with different thresholds in the same process can interfere. Defaults to0.5.
- aggregate(scores: Sequence[Score]) float[source]
Corpus ANLS = mean of per-item ANLS (each is already length-normalized).
Unlike CER/WER (which accumulate global edit counts) or field-F1 (micro-TP/ FP/FN), ANLS is defined as the average normalized similarity over the evaluation set – the “A” in ANLS – so the corpus statistic is the plain mean of the per-item scores.
- class ek.AnnotatedExtraction(grammar: GraphGrammar, estimates: NodePath, ~ek.base.FieldEstimate]=<factory>)[source]
Layer B: the verification metadata, referencing a frozen Layer-A grammar.
The grammar is held by reference and never mutated;
estimatesmaps aNodePathto theFieldEstimatefor that node/field.
- class ek.Calibrator(*args, **kwargs)[source]
Maps a raw scalar signal to a probability after being
fiton labels.
- class ek.Canonicalizer(steps: Sequence[str | Callable[[str], str]] = (), *, version: str = '', audit_marks: bool = False)[source]
An ordered, named, versioned pipeline of
str -> strsteps.- Parameters:
steps – Step references – names registered under
"normalizers"or plain callables. Applied left to right.version – Override the auto-derived version string (the
+-joined names).audit_marks – If true, warn when a step would run on text carrying combining marks (the cross-script damage guard).
- property steps: list
The resolved step names, in order.
- property version: str
Stable identifier of this pipeline (persist it next to a gold set).
- class ek.Cell(text: str = '', rowspan: int = 1, colspan: int = 1)[source]
One logical table cell: its text plus its row/column span (>=1 each).
- class ek.ConformalGate(alpha: float = 0.1, nonconformity: ~typing.Callable[[float], float] = <function ConformalGate.<lambda>>, q: float = inf, _cal: ~typing.List[float] = <factory>)[source]
Split-conformal accept/flag with a finite-sample marginal guarantee.
Fit on
(calibrated_prob, correct): the nonconformity scores of the correct calibration items set a thresholdqsuch that, among truly-correct test items exchangeable with them, at mostalphaare flagged. The guarantee is marginal, not per-field-type – useGroupConformalGatefor that.- Parameters:
alpha – Target false-flag rate on correct items (e.g.
0.1).nonconformity – Maps calibrated confidence -> nonconformity (default
1 - confidence: low confidence is nonconforming).
- fit(probs: Sequence[float], correct: Sequence[bool]) ConformalGate[source]
Fit
qfrom the nonconformity scores of the correct calibration items.Non-finite confidences are dropped (a single
NaNwould otherwise sort to the end and corrupt the quantile, silently breaking the coverage guarantee).
- class ek.Corrector(*args, **kwargs)[source]
A
Validatorthat may also CORRECT.Same callable shape as a Validator, but it can emit a
severity=CORRECTFindingwhosesuggestionis the proposed replacement value. Thelayerattribute names its place on the cheap->expensive spine.
- class ek.CostSensitiveGate(rho: float = 1.0, block_threshold: float = 0.0, accept_threshold: float | None = None)[source]
Accept/flag/block from the cost ratio
rho = c_FN / c_FPon calibratedp.Accept when the expected cost of accepting is below that of routing to a human. Accepting risks an undetected error: cost
(1 - p) * c_FN. Flagging a value that was actually correct wastes a review: costp * c_FP(the false-positive cost is incurred only with probabilityp, not unconditionally). So accept iff(1 - p) * c_FN <= p * c_FP, i.e. the Bayes-optimal thresholdp >= c_FN / (c_FN + c_FP) = rho / (1 + rho). A largerrho(misses much costlier than reviews) raises the bar to auto-accept (tau -> 1asrho -> inf);rho = 1gives the symmetrictau = 0.5.block_thresholdadds an optional hard-fail band at the bottom.- Parameters:
rho – Cost ratio
c_FN / c_FP(>= 0). The single lever; no magic numbers.block_threshold – Calibrated probability at or below which to
blockrather thanflag(default0-> never block on probability alone; a failed verifier is what forces a block, upstream).accept_threshold – Override the derived accept threshold explicitly.
- property tau: float
The accept threshold on calibrated probability (derived from
rho).
- class ek.Decision(value)[source]
Terminal action a selective-prediction gate emits for an extracted value.
- class ek.DecisionPolicy(*args, **kwargs)[source]
Turns a (calibrated) confidence into an accept/flag/block
Decision.
- class ek.EdgeType(name: str, src: str, dst: str, importance: float = 1.0)[source]
A directed relation kind between two
NodeTypenames.
- class ek.Episode(task_id: str = '', trajectory: Trajectory = <factory>, output: Any = None, final_state: Any = None, cost: Cost | None = None, success: bool | None = None, run: RunProvenance | None = None, provenance: Provenance | None = None, meta: dict = <factory>)[source]
Layer B: one agent run over one task – the object agent evaluation scores.
The direct analog of
AnnotatedExtraction: it rides alongside the frozen Layer-A grammar and carries the run’s metadata. It is scored offline against gold (ek.score()/ek.evaluate()) and estimated online against a judge or a consensus (ek.estimate_quality()) – the same object, both halves.- Parameters:
task_id – Which task this episode answers (the k-trial grouping key).
trajectory – The tool calls taken.
output – The agent’s final user-facing answer.
final_state – The end state of the world (the DB, the filesystem) – what a state-based oracle actually grades. Success is a final-state check, not a surface-text check.
cost – The
Costconsumed.success – Filled in by a checker;
Noneuntil graded.run – The
RunProvenance(seed, model, simulator, suite version).provenance – Optional core
Provenancefor click-to-source audit.meta – Anything else the bridge captured.
- property calls: tuple
The
(tool, args)pairs taken, in order.
- class ek.FieldEstimate(value: Any, raw_signals: dict = <factory>, confidence: float | None = None, findings: tuple = (), provenance: Provenance | None = None, decision: Decision | None = None)[source]
One extracted value plus all of its reference-free verification metadata.
- class ek.FieldMetric(*, canonicalizer=None)[source]
Per-field precision/recall/F1 for two dict records.
- Parameters:
canonicalizer – Optional
str -> strapplied to string values before comparison (e.g. casefold/whitespace folding).
- class ek.FieldSpec(name: str, type: str = 'string', importance: float = 1.0, domain: tuple = (), normalizer: str | None = None)[source]
A leaf attribute of a node: its type, validation domain and cost weight.
- Parameters:
name – Attribute name.
type – A free-form type tag (e.g.
"string","number","date");ekdoes not impose a type system – the tag drives metric/validator dispatch and is yours to define.importance – Per-field cost weight; the lever that makes “two extra digits on a donation amount” outweigh “a misspelled city”. Defaults to
1.0.domain – Optional allowed values: an enum tuple, a
(lo, hi)range, or a regex string – consumed by validators, never by metrics.normalizer – Optional key naming a canonicalizer (resolved from the registry) to apply to this field before comparison.
- class ek.Finding(field: str, layer: str, severity: Severity = Severity.FLAG, message: str = '', suggestion: Any | None = None)[source]
One validator observation about a value: a flag, optionally a correction.
A
suggestionis present iff the producing layer can correct (only the deterministic canonicalizer and the gated LLM corrector freely do so). The original value is always retained elsewhere; a Finding never mutates.
- class ek.GateResult(passed: bool, metric: str, higher_is_better: bool, tolerance: float, aggregate_current: float | None = None, aggregate_baseline: float | None = None, regressions: dict = <factory>)[source]
Outcome of a
regression_gate()check.
- class ek.GraphGrammar(node_types: Mapping[str, ~ek.base.NodeType]=<factory>, edge_types: Mapping[str, ~ek.base.EdgeType]=<factory>)[source]
The frozen schema SSOT: typed nodes/edges plus their importance weights.
Methods return the cost weight for a referenced type, defaulting to
1.0for anything not declared, so a partial grammar is always usable.
- class ek.GritsMetric(*, structure_only: bool = False)[source]
GriTS-Top / GriTS-Con as a
Metric(pure-Python, P/R aware).- Parameters:
structure_only – When
True, score cell topology only (GriTS-Top); whenFalse(default), average topology with cell-content similarity (GriTS-Con).
- aggregate(scores: Sequence[Score]) float[source]
Corpus GriTS F1: pool overlap and per-table cell counts, then divide once.
GriTS precision = overlap / |pred cells| and recall = overlap / |gold cells|; the corpus statistic micro-averages by summing the numerators and denominators globally – never a mean of per-table F1s.
- class ek.GroupCalibrator(factory: Callable[[], ~typing.Any]=<class 'ek.qe.calibrate.PlattCalibrator'>, by_group: dict = <factory>, pooled: Any = None, kind: str = 'group')[source]
Per-group (Mondrian) calibration: one calibrator per
NodeType/FieldSpec.Distribution-free conditional (per-field-type) coverage is impossible in general (Barber et al. 2019); calibrating separately per group restores it approximately.
fittakes a parallelgroupssequence;__call__routes by group key, falling back to a pooled calibrator for unseen groups.- Parameters:
factory – Zero-arg callable producing a fresh per-group calibrator (default
PlattCalibrator).
- factory
alias of
PlattCalibrator
- fit(scores: Sequence[float], correct: Sequence[bool], *, groups: Sequence[Any]) GroupCalibrator[source]
Fit one calibrator per distinct group key, plus a pooled fallback.
- class ek.GroupConformalGate(alpha: float = 0.1, nonconformity: ~typing.Callable[[float], float] = <function GroupConformalGate.<lambda>>, by_group: dict = <factory>, pooled: ~ek.qe.decide.ConformalGate | None = None)[source]
Mondrian (class-conditional) conformal: one
ConformalGateper group.Restores approximate per-field-type validity by calibrating separately per group key (e.g.
NodeType/FieldSpec), with a pooled fallback for unseen groups.
- class ek.IntrinsicConfidenceSignal(pool: str = 'min', cost_tier: int = 2)[source]
Aggregate an extractor’s per-unit confidences into a field score.
- Parameters:
pool –
"min"(weakest unit),"mean", or"geo_mean"over the per-unit confidences (already probabilities in[0, 1]).cost_tier –
2– free (the posteriors are emitted at inference).
Example
>>> IntrinsicConfidenceSignal(pool="min")([0.99, 0.4, 0.95]) 0.4
- class ek.IsotonicCalibrator(x: List[float] = <factory>, y: List[float] = <factory>, kind: str = 'isotonic')[source]
Isotonic (monotonic non-decreasing) calibration via pool-adjacent-violators.
More flexible than Platt; needs more calibration data and can overfit small sets. Predicts by linear interpolation between fitted points, clipped at the ends.
- fit(scores: Sequence[float], correct: Sequence[bool]) IsotonicCalibrator[source]
Fit the monotonic step function to
(score, correct)pairs.
- class ek.LogprobSignal(aggregator: Any = <function geo_mean>, alpha: float = 0.6, cost_tier: int = 2)[source]
Aggregate token log-probabilities into one field score (a
Signal).- Parameters:
aggregator – A logprob aggregator (name registered under
aggregatorsor a callable). Defaults togeo_mean().alpha – Length penalty passed to
length_normalized()only.cost_tier –
2– free intrinsic signal (already computed at inference).
- aggregator() float
Geometric mean of token probabilities,
exp(mean log p)(perplexity-inverse).
- class ek.MatchScheme(value)[source]
The (backend, scheme) contract a span/slot F1 is computed under.
The two backends legitimately disagree (seqeval ignores other-type tags at the tag level; nervaluate includes them), so the scheme is always explicit. The
SEQEVAL_*members consume BIO/IOBES tag sequences; the others consume SemEval-2013 span lists via nervaluate.- EXACT = 'exact'
exact span boundaries, type ignored.
- Type:
nervaluate exact
- PARTIAL = 'partial'
boundary overlap (half credit), type ignored.
- Type:
nervaluate partial
- SEQEVAL_CONLL = 'seqeval_conll'
seqeval, CoNLL
conlleval-compatible entity F1 (the default seqeval mode).
- SEQEVAL_STRICT = 'seqeval_strict'
span boundaries AND type must match, keyed to a scheme.
- Type:
seqeval strict mode
- STRICT = 'strict'
exact span boundaries AND correct type.
- Type:
nervaluate SemEval-2013 strict
- TYPE = 'type'
correct type with at least some span overlap.
- Type:
nervaluate type
- class ek.Metric(*args, **kwargs)[source]
Compares one prediction to one gold reference, returning a
Score.
- class ek.NodePath(node_id: str, node_type: str, field: str | None = None)[source]
Addresses a node (or one of its fields) in an extracted graph; keys Layer B.
- class ek.NodeType(name: str, fields: Mapping[str, ~ek.base.FieldSpec]=<factory>, importance: float = 1.0)[source]
A node kind in the typed graph (e.g. an “invoice” or a “line item”).
- class ek.PlattCalibrator(a: float = 1.0, b: float = 0.0, max_iter: int = 100, kind: str = 'platt')[source]
Platt scaling:
sigmoid(a * score + b), fit by Newton/IRLS on labels.The default calibrator: works on any scalar (aggregated confidence/logprob), no logits required. Uses Platt’s target smoothing so it does not overfit small calibration sets.
- Parameters:
max_iter – Newton iterations (converges in a handful for a 2-parameter model).
- fit(scores: Sequence[float], correct: Sequence[bool]) PlattCalibrator[source]
Fit
a, bto maximise the likelihood ofcorrectgivenscores.
- class ek.Provenance(engine: str = '', source_span: tuple | None = None, bbox: Any = None, raw_transcripts: Sequence[str] = ())[source]
Where an extracted value came from, for audit and click-to-source review.
bboxis intentionally typedAnysoekcore need not import any OCR package; the OCR instance stores normalized[0, 1]top-left-origin geometry.
- class ek.QualityReport(calibrated_confidence: float | None = None, decision: Decision | None = None, findings: tuple = (), raw_signals: dict = <factory>, provenance: dict = <factory>, per_field: Mapping | None = None)[source]
The result of a reference-free quality estimate for one extraction.
For a single value the scalar fields carry the verdict. For a whole
AnnotatedExtraction,per_fieldmaps eachNodePathto its updatedFieldEstimate(confidence/findings/decision filled in), and the scalar fields summarize:calibrated_confidenceis the weakest field, anddecisionthe most severe (block>flag>accept).
- class ek.Report(metric: str = '', aggregate: float | None = None, n: int = 0, scores: list = <factory>, per_slice: dict = <factory>, detail: dict = <factory>)[source]
Aggregate of many
Scores over a corpus, with optional per-slice cuts.aggregateis the corpus-level headline (computed by the metric’s own aggregator – e.g. globally accumulated WER – not a naive mean of per-item values).per_slicemaps a slice label to its own aggregate.
- class ek.RiskControlGate(target: float = 0.05, loss: ~typing.Callable[[bool], float] = <function RiskControlGate.<lambda>>, loss_bound: float = 1.0, lam: float = 1.0)[source]
Conformal Risk Control: accept
p >= lambdabounding the accepted-error rate.Picks the smallest threshold
lambda(the most coverage) whose finite-sample risk bound is at or belowtarget. The controlled quantity isE[ loss(item) * 1(accepted) ]– with the default 0/1 loss, the population rate of accepted-yet-wrong items.- Parameters:
target – Upper bound on the accepted-error rate (e.g.
0.05).loss –
correct -> lossin[0, loss_bound]; default0if correct else1. Any monotone loss bounded byloss_boundworks.loss_bound – The loss’s upper bound
B– the CRC finite-sample slack is+B/(n+1). Must matchloss’s range or the guarantee is invalid; keep the default1.0for the 0/1 loss.
- fit(probs: Sequence[float], correct: Sequence[bool]) RiskControlGate[source]
Find the smallest accept threshold whose risk bound is <=
target.
- class ek.RoverConsensus(tokens: List[str] = <factory>, slots: List[RoverSlot] = <factory>, agreement: List[float] = <factory>, n_engines: int = 0)[source]
The result of a ROVER pass over N hypotheses.
- property mean_agreement: float
Mean per-position agreement over consensus tokens, a reference-free confidence in
[0, 1]:1.0means every engine agreed at every emitted position; lower means at least one engine dissented.An empty consensus (no token survived voting – NULL won every slot) means the engines agreed on nothing, so this returns
0.0when 2+ engines were fused, NOT a misleading1.0(which would feed a maximal raw confidence to the calibrator/gate). A single engine – or none – is vacuously1.0.
- property text: str
The consensus transcription (space-joined winning tokens).
- class ek.Score(value: float, precision: float | None = None, recall: float | None = None, f1: float | None = None, metric: str = '', detail: dict = <factory>)[source]
The result of comparing one prediction against one reference.
valueis the headline number (higher is better unless a metric documents otherwise). The optional decomposition fields let structured metrics carry precision/recall/F1, an alignment, and raw counts (e.g. edit operations) so that corpus aggregation can be done correctly (e.g. global WER accumulation) rather than by averaging per-item scores.float(score)returnsvalue.
- class ek.Signal(*args, **kwargs)[source]
A reference-free quality signal: extractor output -> raw scalar score.
The
cost_tierattribute (lower is cheaper) lets an escalation policy run cheap signals first and pay for expensive ones only on residual uncertainty.
- class ek.SpanF1Metric(scheme: MatchScheme | None = None, *, tagging_scheme: str = 'IOB2')[source]
Entity/slot precision/recall/F1 under an explicit
MatchScheme.- Parameters:
scheme – REQUIRED. The (backend, scheme) contract – there is deliberately no default, because seqeval and nervaluate disagree and a bare F1 is ambiguous (
misc/docs/ek_02§2.2). Pass aMatchScheme.tagging_scheme – For
MatchScheme.SEQEVAL_STRICTonly: the BIO/IOBES tagging scheme name (e.g."IOB2","IOBES","BILOU") that strict-mode entity decoding is keyed to. Defaults to"IOB2".
- Raises:
TypeError – if
schemeis omitted (the whole point of this metric).
- aggregate(scores: Sequence[Score]) float[source]
Micro-averaged F1 over the corpus (sum the raw counts, then divide once).
seqeval schemes carry TP/FP/FN; nervaluate schemes carry COR/INC/PAR/MIS/SPU (PAR worth half). Both micro-average by summing counts globally – never a mean of per-document F1s. The two backends’ counts are not commensurable, so a corpus mixing them is rejected rather than silently dropping one.
- class ek.StringMetric(mode: str = 'cer', *, canonicalizer=None)[source]
CER (
mode='cer') or WER (mode='wer') as aMetric.- Parameters:
mode –
"cer"(character) or"wer"(word) error rate.canonicalizer – Optional
str -> strapplied to both sides before scoring (seeek.canonicalize). Usually supplied by the facade instead.
- class ek.Table(rows: Sequence[Sequence[Cell]] = ())[source]
A table as an ordered list of rows of
Cells (the logical cells).rows[i]is the i-th row’s logical cells in reading order; spans are carried on eachCell. Usefrom_html()/from_grid()to build one, andas_grid()for the dense 2-D occupancy view GriTS scores.- as_grid() list[list[Cell | None]][source]
Expand spans into a dense 2-D grid; a spanned position repeats its cell.
Each physical grid position holds the (shared)
Cellcovering it, so two tables with the same topology have grids of the same shape – the view GriTS aligns position-by-position.
- classmethod coerce(obj: Any) Table[source]
Coerce a
Table, an HTML string, or a 2-D grid into a Table.
- class ek.TaskSpec(task_id: str, input: Any = None, gold: Any = None, value: float = 1.0, tools: Sequence[ToolSpec] = (), slice: str | None = None)[source]
One task in a suite: its input, its oracle, its value, and its allowed tools.
- Parameters:
task_id – Stable identifier (the grouping key for k-trial reliability).
input – Whatever the agent under test is called with.
gold – The reference the checker grades against (a goal state, an expected answer, a gold trajectory – the checker decides how to read it).
value – Task-level cost weight: how much a completed task is worth. The task-level analog of
FieldSpec.importance; feeds value-weighted cost accounting.tools – The tools this task permits (the Layer-A grammar for the task).
slice – Optional slice label (domain, difficulty, language) – per-slice cuts are mandatory, not optional (a low pass^k concentrated in one hard slice is a different problem from uniform flakiness).
- grammar() GraphGrammar[source]
The Layer-A grammar for this task’s tool set.
- class ek.TedsMetric(*, structure_only: bool = False)[source]
TEDS / TEDS-Struct as a
Metric(clean-room onapted).- Parameters:
structure_only – When
True, ignore cell text and score structure alone (TEDS-Struct), isolating table-structure accuracy from OCR noise. Defaults toFalse(full TEDS: structure + content).
- aggregate(scores: Sequence[Score]) float[source]
Corpus TEDS = mean of per-table TEDS (the PubTabNet/OmniDocBench convention). Each per-table TEDS is already node-count-normalized, so the reported corpus statistic is the average, not a pooled edit-rate. The pooled
1 - sum(dist)/sum(nodes)is available from the per-itemdetailif a size-weighted variant is wanted instead.
- class ek.TemperatureCalibrator(T: float = 1.0, kind: str = 'temperature', t_min: float = 0.05, t_max: float = 10.0, max_iter: int = 60)[source]
Temperature scaling:
sigmoid(logit / T)with oneTfit on a holdout.Use only when you have logits:
__call__expects a logit, not a probability.T > 1softens overconfidence; the argmax is unchanged.- fit(logits: Sequence[float], correct: Sequence[bool]) TemperatureCalibrator[source]
Fit
Tby minimising NLL with a bounded 1-D search.
- t_min: float = 0.05
Golden-section search bounds and iteration count (config, not magic numbers).
- class ek.ToolSpec(name: str, params: Mapping[str, ~ek.base.FieldSpec]=<factory>, importance: float | None = None, destructive: bool = False)[source]
One tool the agent may call: its argument schema and its error costs.
- Parameters:
name – The tool/function name (the AST-match ground truth).
params – Argument name ->
FieldSpec. Each spec’simportanceis the cost of getting that argument wrong, and itsdomainfeeds validators – the same contract as an extracted field.importance – Cost weight of the call itself (a missing or spurious call). Defaults to
DESTRUCTIVE_WEIGHTwhendestructiveis set, else 1.0.destructive – Whether calling this tool mutates the world irreversibly (a refund, a delete, a send). Drives the default weight and the safety validators.
- property weight: float
The call-level cost weight (explicit, else destructive-aware default).
- class ek.TypeRef(kind: str, name: str, field: str | None = None)[source]
A reference into the schema, so an injected cost function can read weights.
kindis one of"node","edge"or"field"; for a field,nameis the node-type name andfieldthe field name.
- class ek.TypedEdge(src: str, dst: str, edge_type: str = '')[source]
A directed, typed relation between two node ids.
- class ek.TypedGraph(nodes: Sequence[TypedNode] = (), edges: Sequence[TypedEdge] = ())[source]
A typed graph: typed nodes plus directed typed edges between them.
- class ek.TypedGraphMetric(grammar: GraphGrammar | None = None, weights: Callable[[GraphGrammar, TypeRef], float] | None = None, *, canonicalizer=None, timeout: float = 10.0, max_nodes: int = 60)[source]
Cost-weighted, type-aware typed-graph edit distance as a
Metric.- Parameters:
grammar – Layer-A
GraphGrammarsupplying cost weights (may also be passed per-call to__call__).weights – Optional
CostWeightoverriding the schema weights.canonicalizer – Optional
str -> strapplied to field values before the equality check (the facade passesnormalize=through here); a schema’s per-fieldFieldSpec.normalizertakes precedence for the fields it names.timeout – Seconds budget for the (NP-hard) GED search; on timeout the best cost found so far is used (an approximation). Default 10.0.
- class ek.TypedNode(node_id: str, node_type: str, fields: Mapping[str, ~typing.Any]=<factory>)[source]
A node in a typed graph: an id, a node-type tag, and its field values.
- class ek.ValidationResult(original: Any, value: Any, findings: tuple[Finding, ...] = ())[source]
The outcome of running a value through a
validation_pipeline().valueis the final value after any applied corrections;originalis the input;findingsis the full audit trail (FLAG and CORRECT), in layer order.- property clean: bool
No findings at all – the value passed every layer untouched.
- property corrected: bool
Whether any correction changed the value.
- property flagged: bool
Whether any layer raised a FLAG (something a human should look at).
- class ek.Validator(*args, **kwargs)[source]
Reference-free check on a value, yielding zero or more
Findings.
- class ek.VerifierSignal(validators: ~typing.List[~typing.Callable[[...], ~typing.Iterable[~ek.base.Finding]]] = <factory>, cost_tier: int = 1)[source]
Deterministic verifier evidence as a reference-free
Signal.Cost tier 1 – the free, first-to-run layer. Runs a list of
Validators on a value and returns the fraction that passed in[0, 1]as the raw signal (1.0= every check passed). It also exposes theFindings it produced viafindings(), so the same object feeds both the score path (-> calibrate) and the audit path (-> review). Like every signal it is uncalibrated: a verifier failing is strong evidence, not a probability, so aCalibratorstill runs before any gate.Example
>>> v = VerifierSignal([checksum_validator("luhn")]) >>> v("79927398713") # valid -> all checks pass 1.0 >>> v("79927398710") # invalid -> the one check fails 0.0
- ek.benford_findings(numbers: Iterable[Any], *, field: str = '', tol: float = 0.15, min_n: int = 30, layer: str = 'anomaly') list[source]
FLAG a numeric field whose first-digit distribution deviates from Benford’s law.
A reference-free anomaly check for naturally-occurring magnitudes (amounts, populations, counts): the first significant digit should follow
P(d)=log10(1+1/d). A large deviation flags fabricated or systematically-wrong data. Corpus-level (takes the whole column of values) and FLAG-only – it routes a field to review, never auto-edits, and can false-positive on legitimately bounded/skewed fields. Skipped (returns[]) belowmin_nusable values, since the law is asymptotic.
- ek.cache_this(func: Callable[[Any], VT] = None, *, cache: str | MutableMapping[KT, VT] | None = None, key: KT | Callable[[str], KT] | None = None, pre_cache: bool | MutableMapping = False, as_property: bool | None = None, ignore: str | list[str] | None = None, serialize: Callable[[Any], Any] | None = None, deserialize: Callable[[Any], Any] | None = None)[source]
Unified caching decorator for properties and methods with persistent storage support.
cache_this extends the capabilities of Python’s built-in functools.cached_property and functools.lru_cache by providing:
Persistent caching: Store cached values in files, databases, or any MutableMapping
Flexible cache backends: Use instance attributes, external stores, or cache factories
Smart key generation: Automatic argument-based keys for methods with parameter filtering
Serialization support: Custom serialize/deserialize functions for complex data
Auto-detection: Automatically chooses property vs method caching based on signature
No LRU eviction: Unlike lru_cache, values persist until explicitly removed
Unlike functools.cached_property (properties only) and lru_cache (memory-only with eviction), cache_this provides a unified interface for both use cases with persistent storage options.
- Parameters:
func – The function to be decorated (usually left empty).
cache –
The cache storage. Can be: - A MutableMapping instance (shared across instances) - A string naming an instance attribute containing a MutableMapping - A callable taking (instance) and returning a MutableMapping
This enables instance-specific caching, e.g.: cache=lambda self: Files(f’/cache/{self.user_id}/’)
key – For properties: the key to store the cache value, can be a callable that will be applied to the method name to make a key, or an explicit string. For methods: a callable that takes (self, *args, **kwargs) and returns a cache key.
pre_cache – Default is False. If True, adds an in-memory cache to the method to (also) cache the results in memory. If a MutableMapping is given, it will be used as the pre-cache. This is useful when you want a persistent cache but also want to speed up access to the method in the same session.
as_property – If True, force use of CachedProperty. If False, force use of CachedMethod. If None (default), auto-detect based on function signature.
ignore – Parameter name(s) to exclude from cache key computation. Can be a string (single parameter) or list of strings (multiple parameters). Commonly used to ignore ‘self’ or parameters like ‘verbose’ that don’t affect the result.
serialize – Optional function to serialize values before caching. Example: serialize=pickle.dumps for binary file storage
deserialize – Optional function to deserialize cached values. Example: deserialize=pickle.loads
- Returns:
The decorated function.
## Comprehensive Example
Here’s a complete example showcasing all major features of cache_this:
>>> import tempfile >>> import os >>> from pathlib import Path >>> >>> class DataProcessor: ... def __init__(self, user_id="user123"): ... self.user_id = user_id ... self.memory_cache = {} # In-memory cache ... self.call_counts = {} # Track function calls for demo ... ... # 1. Basic property caching (like functools.cached_property) ... @cache_this ... def basic_property(self): ... '''Cached in instance.__dict__ by default''' ... self.call_counts['basic_property'] = self.call_counts.get('basic_property', 0) + 1 ... return f"computed_value_{self.call_counts['basic_property']}" ... ... # 2. Property with custom cache and key ... @cache_this(cache='memory_cache', key='custom_prop_key') ... def custom_cached_property(self): ... '''Cached in instance.memory_cache with custom key''' ... self.call_counts['custom_cached_property'] = self.call_counts.get('custom_cached_property', 0) + 1 ... return f"custom_value_{self.call_counts['custom_cached_property']}" ... ... # 3. Method caching with argument-based keys ... @cache_this(cache='memory_cache') ... def compute_result(self, x, y, mode='fast'): ... '''Cached based on arguments (x, y, mode)''' ... key = ('compute_result', x, y, mode) ... self.call_counts[key] = self.call_counts.get(key, 0) + 1 ... return x * y * (2 if mode == 'fast' else 3) ... ... # 4. Method caching with ignored parameters ... @cache_this(cache='memory_cache', ignore={'verbose', 'debug'}) ... def process_data(self, data, algorithm='default', verbose=False, debug=False): ... '''Cache ignores verbose and debug parameters''' ... key = ('process_data', tuple(data), algorithm) ... self.call_counts[key] = self.call_counts.get(key, 0) + 1 ... if verbose: print(f"Processing {data} with {algorithm}") ... return sum(data) * (2 if algorithm == 'default' else 3) ... ... # 5. Instance-specific cache factory ... @cache_this(cache=lambda self: {f'{self.user_id}_cache': {}}.get(f'{self.user_id}_cache')) ... def user_specific_computation(self, value): ... '''Each instance gets its own cache based on user_id''' ... key = ('user_specific_computation', value) ... self.call_counts[key] = self.call_counts.get(key, 0) + 1 ... return value ** 2
Now let’s test all the features:
>>> processor = DataProcessor("alice") >>> >>> # Test basic property caching >>> result1 = processor.basic_property >>> result2 = processor.basic_property # Should use cache >>> assert result1 == result2 == "computed_value_1" >>> assert 'basic_property' in processor.__dict__ # Cached in instance dict >>> >>> # Test custom cache and key >>> result1 = processor.custom_cached_property >>> result2 = processor.custom_cached_property # Should use cache >>> assert result1 == result2 == "custom_value_1" >>> assert 'custom_prop_key' in processor.memory_cache >>> >>> # Test method caching with arguments >>> result1 = processor.compute_result(3, 4, 'fast') >>> result2 = processor.compute_result(3, 4, 'fast') # Should use cache >>> result3 = processor.compute_result(3, 4, 'slow') # Different args, new computation >>> assert result1 == result2 == 24 # 3 * 4 * 2 >>> assert result3 == 36 # 3 * 4 * 3 >>> >>> # Test parameter ignoring >>> result1 = processor.process_data([1, 2, 3], verbose=True) Processing [1, 2, 3] with default >>> result2 = processor.process_data([1, 2, 3], verbose=False) # Should use same cache >>> result3 = processor.process_data([1, 2, 3], debug=True) # Should use same cache >>> assert result1 == result2 == result3 == 12 # sum([1,2,3]) * 2 >>> >>> # Test instance-specific caching >>> result1 = processor.user_specific_computation(5) >>> result2 = processor.user_specific_computation(5) # Should use cache >>> assert result1 == result2 == 25 # 5 ** 2 >>> >>> # Different instance should have separate cache >>> processor2 = DataProcessor("bob") >>> result3 = processor2.user_specific_computation(5) # Fresh computation >>> assert result3 == 25
Used with no arguments, cache_this will cache just as the builtin cached_property does – in the instance’s __dict__ attribute.
>>> class SameAsCachedProperty: ... @cache_this ... def foo(self): ... print("In SameAsCachedProperty.foo...") ... return 42 ... >>> obj = SameAsCachedProperty() >>> obj.__dict__ # the cache is empty {} >>> obj.foo # when we access foo, it's computed and returned... In SameAsCachedProperty.foo... 42 >>> obj.__dict__ # ... but also cached {'foo': 42} >>> obj.foo # so that the next time we access foo, it's returned from the cache. 42
Not that if you specify cache=False, you get a property that is computed every time it’s accessed:
>>> class NoCache: ... @cache_this(cache=False) ... def foo(self): ... print("In NoCache.foo...") ... return 42 ... >>> obj = NoCache() >>> obj.foo In NoCache.foo... 42 >>> obj.foo In NoCache.foo... 42
Specify the cache as a dictionary that lives outside the instance:
>>> external_cache = {} >>> >>> class CacheWithExternalMapping: ... @cache_this(cache=external_cache) ... def foo(self): ... print("In CacheWithExternalMapping.foo...") ... return 42 ... >>> obj = CacheWithExternalMapping() >>> external_cache {} >>> obj.foo In CacheWithExternalMapping.foo... 42 >>> external_cache {'foo': 42} >>> obj.foo 42
Specify the cache as an attribute of the instance, and an explicit key:
>>> class WithCacheInInstanceAttribute: ... ... def __init__(self): ... self.my_cache = {} ... ... @cache_this(cache='my_cache', key='key_for_foo') ... def foo(self): ... print("In WithCacheInInstanceAttribute.foo...") ... return 42 ... >>> obj = WithCacheInInstanceAttribute() >>> obj.my_cache {} >>> obj.foo In WithCacheInInstanceAttribute.foo... 42 >>> obj.my_cache {'key_for_foo': 42} >>> obj.foo 42
Now let’s see a more involved example that exhibits how cache_this would be used in real life. Note two things in the example below.
First, that we use functools.partial to fix the parameters of our cache_this. This enables us to reuse the same cache_this in multiple places without all the verbosity. We fix that the cache is the attribute cache of the instance, and that the key is a function that will be computed from the name of the method adding a ‘.pkl’ extension to it.
Secondly, we use the ValueCodecs from dol to provide a pickle codec for storying values. The backend store used here is a dictionary, so we don’t really need a codec to store values, but in real life you would use a persistent storage that would require a codec, such as files or a database.
Thirdly, we’ll use a pre_cache to store the values in a different cache “before” (setting and getting) them in the main cache. This is useful, for instance, when you want to persist the values (in the main cache), but keep them in memory for faster access in the same session (the pre-cache, a dict() instance usually). It can also be used to store and use things locally (pre-cache) while sharing them with others by storing them in a remote store (main cache).
Finally, we’ll use a dict that logs any setting and getting of values to show how the caches are being used.
>>> from dol import cache_this >>> >>> from functools import partial >>> from dol import ValueCodecs >>> from collections import UserDict >>> >>> >>> class LoggedCache(UserDict): ... name = 'cache' ... ... def __setitem__(self, key, value): ... print(f"In {self.name}: setting {key} to {value}") ... return super().__setitem__(key, value) ... ... def __getitem__(self, key): ... print(f"In {self.name}: getting value of {key}") ... return super().__getitem__(key) ... >>> >>> class CacheA(LoggedCache): ... name = 'CacheA' ... >>> >>> class CacheB(LoggedCache): ... name = 'CacheB' ... >>> >>> cache_with_pickle = partial( ... cache_this, ... cache='cache', # the cache can be found on the instance attribute `cache` ... key=lambda x: f"{x}.pkl", # the key is the method name with a '.pkl' extension ... pre_cache=CacheB(), ... ) >>> >>> >>> class PickleCached: ... def __init__(self, backend_store_factory=CacheA): ... # usually this would be a mapping interface to persistent storage: ... self._backend_store = backend_store_factory() ... self.cache = ValueCodecs.default.pickle(self._backend_store) ... ... @cache_with_pickle ... def foo(self): ... print("In PickleCached.foo...") ... return 42 ...
>>> obj = PickleCached() >>> list(obj.cache) []
>>> obj.foo In CacheA: getting value of foo.pkl In CacheA: getting value of foo.pkl In PickleCached.foo... In CacheA: setting foo.pkl to b'\x80\x04K*.' 42 >>> obj.foo In CacheA: getting value of foo.pkl In CacheB: setting foo.pkl to 42 42
As usual, it’s because the cache now holds something that has to do with foo:
>>> list(obj.cache) ['foo.pkl'] >>> # == ['foo.pkl']
The value of ‘foo.pkl’ is indeed 42:
>>> obj.cache['foo.pkl'] In CacheA: getting value of foo.pkl 42
But note that the actual way it’s stored in the _backend_store is as pickle bytes:
>>> obj._backend_store['foo.pkl'] In CacheA: getting value of foo.pkl b'\x80\x04K*.' >>> # == b'\x80\x04K*.'
- ek.canonicalize_corrector(normalize: Any, *, field_name: str = '', layer: str = 'canonicalize') Corrector[source]
L0: fold a value to canonical form, emitting a CORRECT finding when it changes.
normalizeis anythingek.canonicalize.resolve_canonicalizer()accepts (a registered name, a callable, a step list). The narrowest, safest corrector – run it first so later layers compare canonical forms.
- ek.check_requirements(*, engine: str | None = None, extra: str | None = None) dict[source]
Report what an OCR engine or feature extra needs, without raising.
For an OCR
engineand an installedocracy, defers toocracy’s own doctor (which knows each backend’s pip package, system binaries and credentials). Otherwise returns a generic hint.- Returns:
A dict with at least
{"ok": bool, "hint": str}.
- ek.checksum_validator(kind: str = 'luhn', *, field_name: str = '', layer: str = 'checksum') Callable[[...], Iterable[Finding]][source]
A
Validatorthat flags a value failing a named checksum.
- ek.cohen_kappa(rater_a: Sequence, rater_b: Sequence) float[source]
Cohen’s kappa for two raters on nominal labels (chance-corrected agreement).
Use only for exactly two raters, nominal labels, complete data; prefer
krippendorff_alpha()for the general case (missing data, any measurement level).
- ek.cross_field_validator(predicate: Callable[[Any], bool], *, message: str, fields: Sequence[str] = (), layer: str = 'cross_field') Callable[[...], Iterable[Finding]][source]
FLAG a record when
predicate(record)is False (a general cross-field check).Skips silently when
predicateraises on a partial record (a missing field is not itself a cross-field violation).
- ek.default_canonicalizer() Canonicalizer[source]
A conservative default: NFC -> casefold -> collapse whitespace.
- ek.default_cost_weight(grammar: GraphGrammar, ref: TypeRef) float[source]
The default
CostWeight: read the importance weight off the schema.
- ek.engine_yields_tables(engine: str) bool[source]
Whether
engine’s capability profile claims scoreable table structure.A convenience over
ek.ocr.profile(): returns the engine profile’stablesflag. ek treats the profile as a prior (Azure DI, AWS Textract, PaddleOCR and the markdown engines are expected to yield scoreable tables; Google Vision is not), to be verified empirically by actually runningtable_from_ocr(). Use it to skip table scoring for engines that cannot emit cells in the first place.
- ek.enum_validator(members: Sequence[Any], *, field_name: str = '', layer: str = 'enum') Callable[[...], Iterable[Finding]][source]
A
Validatorthat flags a value not in an allowed set.
- ek.estimate_quality(extraction: Any, *, sources: Iterable = (), signals: Iterable = (), calibrator=None, validators: Iterable = (), policy=None, agreement: bool = True, assume_calibrated: bool = False) QualityReport[source]
Estimate the quality of an extraction with no gold reference.
Composes the strict
signal -> calibrate -> validate -> decidepipeline (misc/docs/ek_03) over a single value, aFieldEstimate, or a wholeAnnotatedExtraction(scored per field, with per-field specs feeding the validators and the node type as the Mondrian group key). The simple callestimate_quality(value)Just Works; every stage is injectable.- Parameters:
extraction – A raw value, a
FieldEstimate, or anAnnotatedExtraction.sources – Additional hypotheses of the same content (strings or
OcrResult-shaped objects) to fuse with ROVER – their mean agreement becomes araw_signals["agreement"]entry (the flagship online signal).signals – Explicit
Signalcallablestarget -> float | Mappingproducing further raw signals.calibrator – A
Calibratormapping raw score -> probability. Calibration is non-optional before gating (Hard Rule 1): passing apolicywith no calibrator andassume_calibratedfalse raises – a raw, uncalibrated score must never reach a DecisionPolicy.validators –
Validatorcallables yielding findings. A flat iterable runs on every field (use spec-driven validators likeschema_validator()). For per-field scoping pass aMappingkeyed by field name or node type ("*"runs on all).policy – A
DecisionPolicyproducing accept/flag/block from the calibrated confidence.agreement – Auto-run ROVER over
sourceswhen any are given (default true).assume_calibrated – Treat the incoming confidence as already calibrated (silences the uncalibrated-gating warning).
- ek.evaluate(cases: Iterable, *, metric: None | str | Metric = None, grammar: GraphGrammar | None = None, normalize: Any = None, weights: Any = None) Report[source]
Aggregate many comparisons into a
Report(corpus level).- Parameters:
cases – Iterable of
(pred, gold)or(pred, gold, slice_label)tuples.metric – As in
score()(resolved once from the first case’s types).grammar – Optional Layer-A grammar passed to every comparison.
normalize – Optional canonicalizer applied before every comparison.
- Returns:
A Report whose
aggregateis computed by the metric’s own aggregator (e.g. globally accumulated CER/WER, micro-F1) – never a naive mean.
- ek.evaluate_store(predict: Callable[[Any], Any], gold: Mapping, *, metric: str | None = None, grammar: Any = None, normalize: Any = None, weights: Any = None, input_key: str = 'input', reference_key: str = 'reference', slice_key: str = 'slice', persist: bool = False, run_id: str | None = None, rootdir: str | None = None)[source]
Run
predictover a gold store and score it per slice.- Parameters:
predict –
input -> predictioncallable (the system under test).gold – Mapping
key -> {input, reference, [slice], ...}(a dict or aekgold store).metric – Metric name/callable (defaults to type-dispatch on the first case).
grammar/normalize/weights – forwarded to scoring.
input_key/reference_key/slice_key – field names within each gold record.
persist/run_id/rootdir – persist the run to the
results/runsstores.
- Returns:
A
Report;detail['per_item']maps each key to its prediction, reference, slice, and score.
- ek.expected_calibration_error(probs: Sequence[float], correct: Sequence[bool], *, n_bins: int = 10) float[source]
Expected Calibration Error: weighted mean gap between confidence and accuracy.
Bins predictions by confidence into
n_binsequal-width bins and averages|mean_confidence - accuracy|weighted by bin population.0is perfect. Non-finite probs are skipped; out-of-range probs are clamped into[0, 1].
- ek.get(namespace: str, name: str) Any[source]
Resolve a registered strategy by name (discovering entry points first).
- Raises:
KeyError – with the list of available names, if
nameis unknown.
- ek.has_table_structure(ocr_result: Any, *, parser: None | str | Callable[[Any], Any] = None) bool[source]
Whether
table_from_ocr()recovers a non-empty table fromocr_result.A thin predicate over
table_from_ocr()(sameparsersemantics): true iff a table with at least one cell can be recovered. Use it to route an OCR result to the table metrics vs the text-only CER/WER fallback.
- ek.iban_check(iban: Any) bool[source]
IBAN validity via the ISO 13616 / ISO 7064 mod-97 rule (must equal 1).
- ek.isbn_check(value: Any) bool[source]
Validate an ISBN-10 or ISBN-13 by length (digits/hyphens/spaces ignored).
- ek.json_store(kind: str, *, rootdir: str | None = None) MutableMapping[source]
A JSON
MutableMappingfor onekindof artifact.Keys are plain names (no extension); values are JSON-serializable objects.
- ek.krippendorff_alpha(reliability_data: Sequence[Sequence], *, level: str = 'nominal') float[source]
Krippendorff’s alpha – the general inter-annotator agreement coefficient.
Pure-Python (no dependency, permissive core): any number of raters, any measurement
level("nominal","ordinal","interval","ratio"), and missing data (useNoneorfloat('nan')for an unlabelled cell).reliability_datais one row per rater, one column per item. Alpha is1.0for perfect agreement,0.0at chance, and goes negative for systematic disagreement. Computed via the coincidence-matrix method (Krippendorff 2011): observed vs expected disagreement under the level-specific difference metric.Example
>>> data = [[1, 1, 1, 1], [1, 1, 1, 1]] # two raters, perfect agreement >>> krippendorff_alpha(data) 1.0
- ek.lexicon_corrector(vocabulary: Iterable[str], *, threshold: float = 0.8, scorer: Any = None, field_name: str = '', layer: str = 'lexicon', flag_unmatched: bool = True) Corrector[source]
L2: resolve a value against a closed vocabulary by fuzzy match (
rapidfuzz).The safest corrector in the stack when the candidate set is closed (country codes, SKUs, enums): an exact member passes untouched; a single close match (similarity
>= threshold) is applied as a CORRECT finding; otherwise the value is FLAGged as out-of-vocabulary (whenflag_unmatched). Against an open vocabulary, setflag_unmatched=Falseand treat matches as suggestions only.- Parameters:
vocabulary – The closed set of valid values.
threshold – Minimum
rapidfuzzsimilarity in[0, 1]to auto-correct.scorer – A
rapidfuzzscorer (defaultfuzz.ratio).flag_unmatched – FLAG a value with no close match (default
True).
- ek.llm_corrector(correct_fn: Callable[[str], str | None], *, only_flagged: bool = True, field_name: str = '', layer: str = 'llm_correct') Corrector[source]
L5: a gated neural/LLM corrector – the only layer that invents content.
correct_fnis an injected(value) -> Optional[str](bring your own LLM/seq2seq call; return a corrected string, orNoneto leave the value unchanged). It is the most expensive and stochastic layer, so by default (only_flagged) it fires only on a value that a cheaper layer already FLAGged – gate it to the residual, never let it silently rewrite clean fields (this layer reads the pipeline’s accumulated findings viawants_findings). Emits a CORRECT finding; the pipeline applies the rewrite and keeps the original for audit. Always verify its output downstream – it can make text worse.Recipe (Anthropic):
correct_fn=lambda v: client.messages.create(model=..., messages=[{"role": "user", "content": prompt(v)}]).content[0].text.strip()withclient = anthropic.Anthropic()(MIT). Constrain/verify the result and keep an audit trail.
- ek.lm_surprisal_validator(scorer: Callable[[str], float], *, threshold: float, higher_is_worse: bool = True, field_name: str = '', layer: str = 'lm_prior') Callable[[...], Iterable[Finding]][source]
L3: FLAG a value whose language-model surprisal crosses
threshold.scoreris an injected(str) -> floatreturning a surprisal / perplexity / negative-log-likelihood (dependency injection: bring your own in-domain n-gram or masked-LM scorer – an in-domain prior is what makes this useful). FLAG-only on its own; pair it with a candidate generator (lexicon_corrector/llm_corrector) to actually correct. Sethigher_is_worse=Falseif your scorer returns a probability (higher = better).Recipe (masked-LM pseudo-log-likelihood): wrap
minicons–from minicons import scorer; m = scorer.MaskedLMScorer("bert-base-uncased", "cpu")– and passscorer=lambda s: -m.sequence_score([s])[0](surprisal = negative PLL).miniconsis MIT; install your own LM backend (it pulls torch).
- ek.load_baseline(name: str, *, rootdir: str | None = None) dict | None[source]
Load a named baseline (or
Noneif it does not exist).
- ek.load_calibrator(name: str, *, rootdir: str | None = None) Any[source]
Reconstruct a persisted calibrator by name (dispatched on its
kind).Validates the stored record so a malformed or unknown
kindfails with an actionable error rather than a rawKeyErroror a load-then-crash-later.
- ek.luhn_check(number: Any) bool[source]
Luhn (mod-10) checksum: credit cards, IMEIs, some national IDs.
- ek.mall(*, rootdir: str | None = None) Mall[source]
The full stores-of-stores mall (one JSON store per
KINDS).
- ek.names(namespace: str) Iterable[str][source]
All registered names in a namespace (after entry-point discovery).
- ek.ordering_validator(keys: Sequence[str], *, strict: bool = True, layer: str = 'cross_field') Callable[[...], Iterable[Finding]][source]
FLAG a record whose comparable values at
keysare not ascending.The canonical date/sequence check (
start <= end, issue before due). Only the keys actually present and mutually comparable are checked, so it is safe on partial records.
- ek.pass_at_k(*, n: int, c: int, k: int) float[source]
Unbiased
pass@k: probability that at least one of k samples succeeds.The HumanEval estimator
1 - C(n-c, k) / C(n, k)overntrials withcsuccesses. This is the capability metric – right when a single success is enough (offline candidate generation behind a verifier).Example
>>> round(pass_at_k(n=10, c=1, k=1), 3) 0.1 >>> pass_at_k(n=10, c=10, k=5) 1.0
- ek.pass_hat_k(*, n: int, c: int, k: int) float[source]
Unbiased
pass^k: probability that all k independent trials succeed.The tau-bench estimator
C(c, k) / C(n, k); for a per-trial success probabilitypit decays top**k. This is the reliability metric – the one that matters when consistency is the product.Example
>>> pass_hat_k(n=8, c=8, k=8) 1.0 >>> pass_hat_k(n=8, c=4, k=2) 0.21428571428571427
- ek.percent_agreement(rater_a: Sequence, rater_b: Sequence) float[source]
Raw fraction of items two raters labelled identically.
- ek.persistent_cache(func: Callable | None = None, *, kind: str = 'runs', key: Any = None, rootdir: str | None = None)[source]
Memoize a plain function’s result in a
kindJSON store, across sessions.Use as
@persistent_cache(kind="runs", key=...). Returned values must be JSON-serializable.keymay be a constant or a callable receiving the call arguments; it defaults to the function’s qualified name (so provide akeyfor functions that take arguments). For caching a class property/method, prefercache_this(dol’s descriptor) instead.Example
>>> import tempfile; root = tempfile.mkdtemp() >>> calls = [] >>> @persistent_cache(kind="runs", key="answer", rootdir=root) ... def compute(): ... calls.append(1) ... return {"answer": 42} >>> compute(), compute(), len(calls) ({'answer': 42}, {'answer': 42}, 1)
- ek.range_validator(lo: float, hi: float, *, field_name: str = '', layer: str = 'range') Callable[[...], Iterable[Finding]][source]
A
Validatorthat flags a number outside[lo, hi].
- ek.regex_validator(pattern: str, *, field_name: str = '', layer: str = 'format') Callable[[...], Iterable[Finding]][source]
A
Validatorthat flags a value not fully matchingpattern.
- ek.register(namespace: str, name: str, obj: Any | None = None) Any[source]
Register
objundernamespace/name(usable as a decorator).- Parameters:
namespace – A strategy family, e.g.
"metrics","signals","calibrators","policies","normalizers","ocr".name – The lookup key within the namespace.
obj – The object to register; omit to use as a decorator.
- Returns:
obj(so it can be used as a decorator).
- ek.regression_gate(report, baseline: Any, *, tolerance: float = 0.0, higher_is_better: bool | None = None, rootdir: str | None = None) GateResult[source]
Fail if
reportregresses beyondtolerancevs a baseline, per slice.- Parameters:
report – The current
Report.baseline – A baseline name (loaded from the
baselinesstore) or a baseline dict fromsave_baseline().tolerance – Allowed drift before a change counts as a regression.
higher_is_better – Override metric-direction inference (CER/WER/graph are lower-is-better; F1/similarity are higher-is-better).
rootdir – data root (when
baselineis a name).
- Returns:
A
GateResult(falsy if any regression was found).
- ek.reliability_curve(probs: Sequence[float], correct: Sequence[bool], *, n_bins: int = 10) List[dict][source]
Per-bin
{confidence, accuracy, count}for a reliability diagram.
- ek.requires_extra(extra: str, *, packages: Iterable[str] | None = None) Callable[source]
Decorator: fail with an actionable install hint if an extra is missing.
The wrapped callable runs only once every package in
packagesimports; the common case (everything installed) adds a single cheap import check.- Parameters:
extra – The extra name, used in the hint
pip install ek[<extra>].packages – Import names to probe (defaults to
[extra]).
Example
>>> @requires_extra("ocr", packages=["definitely_not_installed_pkg"]) ... def run(): ... return "ran" >>> try: ... run() ... except MissingExtraError as e: ... print("ek[ocr]" in str(e)) True
- ek.resolve(namespace: str, ref: Any, *, default: Any | None = None) Any[source]
Coerce a strategy reference to a callable.
refmay be a registered name (str), an already-resolved object, orNone(in which casedefault, itself a name or object, is used).
- ek.resolve_table_parser(ref: str | Callable[[Any], Any]) Callable[[Any], Any][source]
Resolve a table parser reference (a registered name or a callable) to a callable.
Mirrors how other ek strategies resolve from the registry (see
ek.registry.resolve()). Astris looked up under the"table_parsers"namespace; an already-callable parser is returned unchanged.
- ek.risk_coverage_curve(probs: Sequence[float], correct: Sequence[bool], *, thresholds: Sequence[float] | None = None) List[dict][source]
The risk-coverage trade-off:
{threshold, coverage, selective_risk}per point.Coverage is the fraction auto-accepted at each threshold; selective risk is the error rate among the accepted. Lower-left is better; pick the operating point against a target risk or coverage.
- ek.rover(hypotheses: Iterable[Any], *, use_confidence: bool = True, conf_weight: float = 0.5, null_conf: float = 0.7, tokenize: Callable[[Any], List[Tuple[str | None, float]]] | None = None, max_tokens: int | None = 5000) RoverConsensus[source]
Align N hypotheses, vote per slot, and emit consensus + per-position agreement.
- Parameters:
hypotheses – The recognizer/extractor outputs to fuse. Each may be a string, an
OcrResult-shaped object (.text/.mean_confidence), a list of token strings, or a list of(token, confidence)pairs.use_confidence – Blend confidence into the vote (
True) or vote purely by frequency (False, which forcesconf_weightto0).conf_weight – Weight on average confidence vs vote frequency in the slot score (Fiscus’s
1 - alpha);0is frequency-only,1is confidence-only. Ignored whenuse_confidenceisFalse.null_conf – Confidence credited to a NULL vote (an engine that produced no token at a slot) – the lever for how readily a deletion wins.
tokenize – Optional
hypothesis -> [(token, confidence), ...]override; by default_as_units()handles strings/OcrResults/token lists.max_tokens – Reject any hypothesis longer than this (the aligner is
O(N*l*L*L'); an unbounded input is a quadratic time/memory DoS).Nonedisables the guard.
- Returns:
A
RoverConsensuswith the consensustokens/text, the per-slot breakdown, and the per-consensus-tokenagreementusable as a raw signal.
- ek.run_suite(agent: Callable[[TaskSpec], Any], tasks: Any, *, k: int = 1, check: Any = None, metrics: Mapping | None = None, price: ModelPrice | None = None, prices: Mapping[str, ModelPrice] | None = None, run: RunProvenance | None = None, seed: int | None = None, persist: bool = False, run_id: str | None = None, rootdir: str | None = None) ReliabilityReport[source]
Run
agentover a task suite,ktrials per task, and report reliability + cost.- Parameters:
agent – The system under test:
TaskSpec -> Episode(a bare answer is wrapped). It receives the whole spec –.input,.tools,.gold– not just the input. If it accepts a keywordseed, the per-trial seed is passed to it.tasks –
TaskSpecs (or a{task_id: spec}mapping).k – Trials per task.
k > 1is what makespass^kmeaningful – and it only means anything if the agent is genuinely stochastic (the report warns if it is not).check – The success oracle – a registered checker name or an
(episode, gold) -> boolcallable. Defaults to"output"; inject a state-based/executable oracle for a real suite, and run it isolated from the agent.metrics – Optional
{name: Metric}scored on every episode againsttask.gold, with the suite’s Layer-A grammar injected – this is how the tool/argument cost weights reach atool_call/trajectorymetric. Results land inreport.detail["metrics"].price/prices – Rates for costing the episodes (see
ek.agents.cost).run –
RunProvenancefor this run (model, simulator, suite version, scaffold). Recorded on every episode and on any baseline saved from it.seed – Base RNG seed. Trial i of each task uses
seed + i: it is recorded on the episode’s provenance and passed to the agent when the agent accepts aseedkeyword.persist/run_id/rootdir – Persist the run to the
runs/resultsstores.
- Returns:
A
ReliabilityReportcarryingpass@k,pass^k(with a bootstrap CI), the success rate (with a Wilson CI), the per-slice cuts, and the cost report – because reliability without cost is only half the answer.
- ek.save_baseline(report, name: str, *, rootdir: str | None = None) dict[source]
Freeze a report’s aggregate + per-slice scores as a named baseline.
- ek.save_calibrator(calibrator: Any, name: str, *, rootdir: str | None = None) dict[source]
Persist a fitted calibrator’s parameters to the
calibratorsstore.
- ek.schema_validator(value: Any, *, spec: FieldSpec | None = None) Iterable[Finding][source]
A spec-driven
Validator: type anddomainchecks from a FieldSpec.Reads the
FieldSpectypeanddomain– a(lo, hi)numeric range, an enum tuple, or a regex string – and applies the matching check. With no spec it is a no-op, so it composes safely in any validator list.
- ek.score(pred: Any, gold: Any, *, grammar: GraphGrammar | None = None, metric: None | str | Metric = None, normalize: Any = None, weights: Any = None) Score[source]
Score one prediction against one gold reference (reference-based).
- Parameters:
pred – The predicted output (string, record dict, or anything with
.text).gold – The gold reference, same shape as
pred.grammar – Optional Layer-A
GraphGrammar(carries cost weights).metric – A registered metric name (
"cer","wer","fields", …), a callableMetric, orNoneto dispatch by type.normalize – Optional canonicalizer (name, callable, step list, or
Canonicalizer) applied before comparison.weights – A
CostWeightfor cost-weighted metrics (the typed-graph distance); overrides the schema’s importance weights.
- Returns:
A
Score.
- ek.split_conformal_quantile(scores: Sequence[float], alpha: float) float[source]
The split-conformal threshold: the
ceil((n+1)(1-alpha)) / nempirical quantile.scoresare nonconformity scores on an exchangeable calibration set. Returns+infwhennis too small to guarantee1 - alpha(so nothing is flagged – the honest behaviour at that sample size), and-infat thealpha -> 1limit (flag everything).
- ek.stop_on_correction(findings: Sequence[Finding]) bool[source]
Stop policy: end the pass as soon as any layer proposes a correction.
- ek.stop_on_flag(findings: Sequence[Finding]) bool[source]
Stop policy: end the pass at the first FLAG (fail fast).
- ek.table_from_ocr(ocr_result: Any, *, parser: None | str | Callable[[Any], Any] = None) Table | None[source]
Extract a normalized
Tablefrom anOcrResult.The single seam ek’s table metrics (TEDS / GriTS) use to score OCR table output uniformly. Returns the recovered Table, or
Nonewhen the result carries no recoverable table structure.- Parameters:
ocr_result – Any
OcrResult-shaped object. ek reads only its.raw(the engine-specific payload),.markdown(VLM/markdown engines), and.meta– never ocracy types, so anyimage -> OcrResultcallable’s output works.parser –
How to turn the raw payload into a table (dependency injection / open-closed). One of:
None(default): try the built-in safe heuristics –.rawis already aTable, a 2-D grid, or a<table>...</table>HTML string; or.raw/.metais a mapping carrying one of{tables, table, cells, grid, html}; or.markdowncontains an HTML<table>. ReturnsNoneif none match – it never guesses wildly.a
str: the name of a parser registered under"table_parsers"(per-engine extractor), resolved fromek.registry.a callable
(raw) -> Table | grid | html | None: an inline per-engine extractor. It receives the result’s.raw(falling back to the result itself when there is no.raw).
through (Any non-None value an injected parser returns is fed)
:param
Table.coerce(): :param so a parser may yield a Table: :param a 2-D grid: :param or HTML.:- Returns:
A non-empty
Table, orNonewhen no table structure is recoverable. An empty Table (no rows/cells) is normalized toNoneso the predicatehas_table_structure()and downstream metrics see “no table”, not a degenerate one.
- ek.totals_consistent(record: Mapping, *, total_key: str, item_keys: Sequence[str], tol: float = 0.01, layer: str = 'cross_field') Iterable[Finding][source]
Flag when
record[total_key]does not equal the sum ofitem_keys(±tol).The canonical cross-field check (invoice total = Σ line items). Skipped (rather than raising or flagging) when the total or all item keys are absent or non-numeric, so it is safe on partial records.
- ek.validation_pipeline(*layers: ~typing.Callable[[...], ~typing.Iterable[~ek.base.Finding]], apply_corrections: bool = True, stop_when: ~typing.Callable[[~typing.Sequence[~ek.base.Finding]], bool] = <function _never_stop>) Callable[[...], ValidationResult][source]
Compose validators/correctors into a cheapest -> most-expensive pipeline.
layersrun in the given order (the order encodes the cost spine). Each layer is a(value, *, spec) -> Iterable[Finding]callable. Whenapply_corrections(default), the firstCORRECTfinding a layer emits is applied to the value before the next layer runs – so a cheap deterministic fix is what the expensive layers then see (the noisy-channel chain).stop_whenis an injectable early-exit policy over the findings accumulated so far (seestop_on_correction()/stop_on_flag()); the default runs every layer.
- ek.zscore_anomaly_findings(numbers: Iterable[Any], *, field: str = '', threshold: float = 3.5, min_n: int = 30, layer: str = 'anomaly') list[source]
FLAG numeric values that are robust-z-score outliers (median + MAD based).
A reference-free, dependency-free anomaly check that complements
benford_findings()(which checks the leading-digit distribution; this checks individual magnitudes). For a numeric column it computes each value’s modified z-score –0.6745 * (x - median) / MAD– which is robust to the very outliers it is looking for, and FLAGs those whose absolute score exceedsthreshold. One FLAG per outlying value (carrying its index); FLAG-only – it routes a value to review, never edits. Skipped belowmin_n(robust statistics on a handful of points are noise). When the MAD is 0 (a near-constant column, e.g. many identical values plus one outlier) it falls back to the mean-absolute-deviation scale (Iglewicz & Hoaglin), so a lone outlier is still caught; a truly constant column (no spread at all) yields no findings. For multivariate outliers, plug an isolation forest viapyod(already inek[validation]) as a custom validator.