Matching and confidence
OCR text is wrong in predictable ways, so every text lookup in the document library is a distance calculation and every extracted value carries a score. This page covers the distance functions and how to threshold them, the phrase / snippet / table query APIs, and the confidence model that turns spatial signals into a number you can gate on.
Distance functions
Section titled “Distance functions”| Function | Signature | Cost model |
|---|---|---|
Distance | Distance(a, b string) int | Levenshtein over runes; insert/delete/substitute cost 1 |
DamerauDistance | DamerauDistance(a, b string) int | Levenshtein plus adjacent transposition at cost 1 |
OCRAwareDistance | OCRAwareDistance(a, b string) float64 | Levenshtein with fractional substitution cost for known OCR confusions |
Similarity | Similarity(a, b string) float64 | 1 - Distance/max(len(a), len(b)) in runes |
OCRAwareSimilarity | OCRAwareSimilarity(a, b string) float64 | Same ratio over OCRAwareDistance |
All five operate on runes, so a multi-byte character counts as one edit — Distance("café", "cafe")
is 1. Identical strings and two empty strings both give similarity 1.0.
The similarity denominator is the longer string. A short query against a long block scores low even when the query is fully contained in it, so similarity is a poor containment test.
DamerauDistance is implemented and tested, but no shipping matcher calls it — no kis bbox
subcommand and no call a rule can make reaches it. A transposition is never discounted on either
surface; it costs two edits like any other pair of substitutions.
The OCR confusion table
Section titled “The OCR confusion table”OCRAwareDistance discounts 17 substitution pairs — 33 directed table entries, because every pair
except %→9 is registered in both directions:
| Cost | Pairs |
|---|---|
| 0.3 | l↔1, O↔0, o↔0, I↔1, I↔l |
| 0.4 | S↔5, s↔5, B↔8, $↔S, space↔_, ,↔. |
| 0.5 | G↔6, Z↔2, c↔e, n↔h, u↔v |
| 0.6 | %→9 (one direction only) |
Insertions and deletions always cost 1.0. The effect is that SCHEDULE B-l sits 0.3 from
SCHEDULE B-1 while a genuine one-character error sits 1.0 away, so a tight budget can accept OCR
noise and still reject real mismatches.
The table is a compiled-in package variable. There is no config key, environment variable or flag that extends or overrides it.
Match configuration
Section titled “Match configuration”type MatchConfig struct { MaxDistance int CaseSensitive bool OCRAware bool MinSimilarity float64}DefaultConfig() returns MaxDistance: 2, CaseSensitive: false, OCRAware: true,
MinSimilarity: 0.8. Match(target, candidate string, cfg MatchConfig) bool and
BestMatch(target string, candidates []string, cfg MatchConfig) (int, float64) consume it;
BestMatch returns the winning index and its similarity, or -1 plus the best score seen when
nothing clears MinSimilarity.
Choosing thresholds
Section titled “Choosing thresholds”Fuzzy phrase APIs take two integer budgets: maxDistancePerWord, applied to each word
independently, and maxTotalDistance, applied to the accumulated distance across the phrase.
| Budget | Effect |
|---|---|
| per-word 0 | Exact words only. Rejects a 0.3 OCR confusion as hard as a real edit |
| per-word 1 | One substitution per word — the working default for scanned text |
| per-word 2 | Accepts short-word collisions; expect false positives on 3–4 letter anchors |
| total | Caps how much error the whole phrase may accumulate; set to roughly the word count |
NormalizeForMatch(s string) string lowercases, trims and collapses whitespace runs to one space.
GenerateOCRVariants(s string) []string returns s plus every single-character substitution drawn
from the confusion table; it backs Vocabulary.AddOCRConfusions. Variants come back in
nondeterministic order.
Phrase queries
Section titled “Phrase queries”FindPhrase(phrase string, linesBefore, linesAfter int64) []PhraseMatchFindIPhrase(phrase string, linesBefore, linesAfter int64) []PhraseMatchFindIPhrase is the case-insensitive form. A PhraseMatch carries Phrase, Page, Box (the
union of the matched blocks), Blocks, LineIndex, MatchedLine, LinesBefore and LinesAfter.
FindPhraseFuzzy(phrase string, maxDistancePerWord, maxTotalDistance, linesBefore, linesAfter int64) []FuzzyPhraseMatchFindIPhraseFuzzy(phrase string, maxDistancePerWord, maxTotalDistance, linesBefore, linesAfter int64) []FuzzyPhraseMatchFuzzyPhraseMatch embeds PhraseMatch and adds TotalDistance int and MatchedWords []string.
The algorithm finds candidate blocks for the first word within the per-word budget, then walks the
remaining words on the same page, ranking candidates by editDistance*100 + spatialDistance and
taking the minimum. A candidate qualifies spatially if it shares the OCR line id, is horizontally
aligned within a 0.3 baseline tolerance, or belongs to the same reconstructed line.
When the walk yields nothing and the phrase has more than one word, a merged-word fallback strips
all spaces from the phrase and compares it against each whole block with plain Distance. This
recovers phrases the OCR engine welded together (At Closing → AtClosing).
Convenience family
Section titled “Convenience family”All of these match case-insensitively. The fuzzy forms derive the total budget automatically as
maxDistancePerWord × wordCount — you cannot set it separately here.
| Function | Signature |
|---|---|
HasPhrase | (phrase string) bool |
HasPhraseFuzzy | (phrase string, maxDistancePerWord int64) bool |
HasPhraseOnPage | (phrase string, page int64) bool |
HasPhraseOnPageFuzzy | (phrase string, page, maxDistancePerWord int64) bool |
HasPhraseInBox | (phrase string, box geometry.Box) bool |
HasPhraseInBoxFuzzy | (phrase string, box geometry.Box, maxDistancePerWord int64) bool |
HasAnyPhrase | (phrases ...string) bool |
HasAnyPhraseFuzzy | (maxDistancePerWord int64, phrases ...string) bool |
HasAllPhrases | (phrases ...string) bool |
HasAllPhrasesFuzzy | (maxDistancePerWord int64, phrases ...string) bool |
HasAnyPhraseInBox | (box geometry.Box, phrases ...string) bool |
HasAnyPhraseOnPage | (page int64, phrases ...string) bool |
CountPhrase | (phrase string) int64 |
CountPhraseFuzzy | (phrase string, maxDistancePerWord int64) int64 |
CountPhraseOnPage | (phrase string, page int64) int64 |
PhraseNear | (a, b string, maxPixels float64) bool |
PhraseBetween | (startAnchor, endAnchor string) string |
Region-scoped matching
Section titled “Region-scoped matching”Scoping the search to a region first is the only phrase-query path that uses the OCR-aware distance.
ContainingPhraseFuzzy(phrase string, maxDistPerWord, maxTotalDist int64) QueryResultContainingIPhraseFuzzy(phrase string, maxDistPerWord, maxTotalDist int64) QueryResultContainingAnyPhraseFuzzy(phrases string, maxDistPerWord, maxTotalDist int64) QueryResultContainingAnyIPhraseFuzzy(phrases string, maxDistPerWord, maxTotalDist int64) QueryResultThe Any forms split phrases on | and return the first phrase that matches. Words from the
region’s blocks are flattened, quotes and dashes are normalised, then the phrase is slid over the
word stream using OCRAwareDistance. These match at per-word 1 / total 2:
SCHEDULE B-l -> SCHEDULE B-1SCHEDULE B-I -> SCHEDULE B-1LOAN O12345 -> LOAN 0123455CHEDULE A -> SCHEDULE AEXHIBIT “A” -> EXHIBIT "A"ANTI–COERCION -> ANTI-COERCIONWritten as a rule condition:
bbox.Get("page").InRegionPct(0.0, 0.0, 100.0, 20.0).ContainingAnyIPhraseFuzzy("ANTI-COERCION INSURANCE|Anti-Coercion Statement|STATEMENT OF ANTI-COERCION", 2, 4).Count() > 0From the CLI
Section titled “From the CLI”kis bbox phrase -p "Invoice Number" doc.bboxkis bbox phrase -p "total amount due" -B 5 -A 2 doc.bboxkis bbox phrase -p "Inviice Numbr" --fuzzy doc.bboxkis bbox phrase -p "Invoice Number" --fuzzy --max-dist-word 1 --max-dist-total 2 doc.bboxkis bbox phrase -i page1.bbox -i page2.bbox -p "signature required" -f json| Flag | Default | Meaning |
|---|---|---|
-p, --phrase | — | Phrase to search for (required) |
-i, --input | — | Input file; repeatable, also accepted positionally |
-B, --before | 3 | Context lines before the match |
-A, --after | 3 | Context lines after the match |
--fuzzy | false | Enable fuzzy matching |
--case-insensitive | false | Case-insensitive comparison |
--max-dist-word | 2 | Per-word budget (fuzzy mode) |
--max-dist-total | 3 | Total budget (fuzzy mode) |
-f, --format | text | text or json |
-o, --output | stdout | Output file |
From the service
Section titled “From the service”rules.svc runs the same queries from a rule, over a document sent inline on the execute-rule
request. Documents travel under the reserved bbox key, one entry per document name:
{ "name": "schedule-b-check", "bbox": { "page": {"pages": [{"number": 1, "width": 612, "height": 792, "words": []}]} }}rule ScheduleBPresent "schedule B heading, OCR-tolerant" salience 100 { when bbox.Has("page") && bbox.Get("page").InRegionPct(0.0, 0.0, 100.0, 20.0).ContainingPhraseFuzzy("SCHEDULE B-1", 1, 2).Count() > 0 then out.Set("schedule_b", true); Retract("ScheduleBPresent");}The budgets carry the same meaning as the CLI flags — --max-dist-word is maxDistancePerWord,
--max-dist-total is maxTotalDistance — and match the same strings. The service runs rules, so
what a match yields reaches the caller through the out collector rather than a printed report:
write the matched text to out when you need to see it. The full request and response shapes are on
The rules service.
Snippets
Section titled “Snippets”ExtractSnippet(startPhrase, endPhrase string, caseInsensitive, includeEnd bool) *SnippetExtractSnippetFuzzy(startPhrase, endPhrase string, caseInsensitive, includeEnd bool, maxDist int64) *SnippetSnippet is {StartPhrase, EndPhrase, StartPage, EndPage, Lines []string}. The start line is
included; the end line is excluded unless includeEnd. Extraction spans page boundaries. The
result is nil only when the start phrase is not found.
kis bbox snippet -s "Section 5:" -e "Section 6:" doc.bboxkis bbox snippet -s "BEGIN" -e "END" --include-end -f json doc.bboxkis bbox snippet -s "Sectlon 5:" -e "Sectlon 6:" --fuzzy 2 doc.bboxkis bbox snippets --snip "section8|Section 8:|Section 9:" doc.bboxkis bbox snippets --snip "terms|TERMS AND CONDITIONS|SIGNATURE" --snip "privacy|PRIVACY|END" *.bbox--fuzzy takes an integer per-word distance, 0 meaning exact. kis bbox snippets always emits
JSON — an object keyed by snippet name, with a missing snippet present as an empty string rather
than absent — and has no --format flag.
The fuzzy total budget is derived, not settable: maxDist × max(startWordCount, endWordCount),
applied to both phrases. A short end phrase inherits a budget sized by a long start phrase.
Table queries
Section titled “Table queries”Detection, defaults and the row/column model are on Tables. The lookup APIs are:
| Function | Signature | Behaviour |
|---|---|---|
TableNear | (anchorText string) *table.Table | Delegates to TableNearPhrase when the anchor contains a space, otherwise a single-block substring search |
TableNearPhrase | (phrase string) *table.Table | Case-sensitive FindPhrase, falling back to a block contains search |
TableBelow / TableAbove | (anchor string) *TableResult | Nearest table in that direction |
TableBetween | (start, end string) *TableResult | The table bounded by two anchors |
TablesBetween | (start, end string) []*TableResult | Every table bounded by two anchors |
TableBelowFuzzy / TableAboveFuzzy | (anchor string, maxDistPerWord, maxTotalDist int64) *TableResult | Fuzzy anchor forms |
TableBetweenFuzzy | (start, end string, maxDistPerWord, maxTotalDist int64) *TableResult | Fuzzy bounded form |
The two TableNear* calls hand back the raw detected table; everything else returns a TableResult
wrapper with row/cell accessors. SafeTableBelow, SafeTableAbove, SafeTableBetween and their
Fuzzy variants wrap that result so its accessors return empty values instead of panicking on a
missing table. SafeTablesBetween is not a wrapper at all — it calls TablesBetween directly and
returns a plain (possibly nil) slice, which is safe to range over but carries no nil-safe accessors.
kis bbox tables page1.bbox page2.bboxkis bbox tables *.bbox -f csvkis bbox table -a "Invoice Items" doc.bboxkis bbox table -a "Price List" -H "Item" -H "Price" -H "Qty" doc.bboxkis bbox table requires --anchor. -H/--header validates expected column headers
case-insensitively after trimming and is the one subcommand that exits non-zero on a miss:
Header validation failed: missing headers: <list> (found: <list>).
Table confidence is not field confidence
Section titled “Table confidence is not field confidence”A detected Table carries Confidence float64 — (consistencyScore + sizeScore) / 2, where
consistency is 1 / (1 + variance(cellCountsPerRow) × 0.1) and size is 0.5, 0.8 at ≥ 3 rows and
≥ 2 columns, or 1.0 at ≥ 5 rows and ≥ 3 columns — plus a full TableScore. Both are geometry-only:
they say nothing about whether the cell text is right, they are not on the same scale as
field-extraction confidence, and the CLI prints neither.
The confidence model
Section titled “The confidence model”Every confidence number in the library is the same shape:
type Score struct { Value float64 // 0.0 - 1.0 Factors []Factor}
type Factor struct { Name string Value float64 Weight float64 Detail string}FromFactors(factors ...Factor) Score computes the weight-normalised mean and clamps it to
[0,1]; a factor with weight ≤ 0 is coerced to 1.0. New(value float64) Score wraps a bare number.
The Detail strings are what make a value explainable in a reviewer interface.
Built-in factors
Section titled “Built-in factors”| Constructor | Name | Weight | Value |
|---|---|---|---|
OCRFactor(conf) | ocr | 1.0 | OCR engine confidence |
ValidationFactor(ok, name) | validation | 1.0 | 1.0 or 0.0 |
PatternMatchFactor(score, pattern) | pattern_match | 0.9 | Supplied score |
AlignmentFactor(score) | alignment | 0.8 | Supplied score |
ConsistencyFactor(ok, detail) | consistency | 0.8 | 1.0 or 0.0 |
FuzzyMatchFactor(sim, dist) | fuzzy_match | 0.7 | Supplied similarity |
ProximityFactor(dist, maxDist) | proximity | 0.6 | 1 - distance/maxDistance |
CountFactor(found, expected) | count | 0.5 | found/expected |
These weights are the system’s compiled-in policy about what it trusts. They are not configuration.
How a field score is produced
Section titled “How a field score is produced”ScoreFieldExtraction(ExtractionSignals) (confidence.Score, Provenance) is the single place a
label-to-value extraction is scored. It builds two sub-scores and combines them:
Label score — the label block’s OCR confidence (weight 1.0); a label_format factor (weight
0.5) worth 0.9 when the text ends with :, is ALL CAPS with 40 letters or fewer, or starts with a
capital, and 0.6 otherwise; and, when the label matched fuzzily, a fuzzy_match factor of
1 - editDistance/len(requestedLabel).
Value score — the value block’s OCR confidence (weight 1.0); an alignment factor; a proximity factor measured against the search budget; a positional prior for the relation; and a pattern factor when the value matched an expected type.
Overall — WeightedAverage([label, value], [0.4, 0.6]). The value is trusted more than the
label.
| Relation | Positional prior (weight 0.6) | Alignment axis |
|---|---|---|
inline | 0.95 | Flat 1.0 |
right_of | 0.85 | Vertical centre offset against half the label height |
below | 0.75 | Left-edge offset against half the wider box |
left_of | 0.70 | Vertical centre offset against half the label height |
near | 0.70 | Vertical centre offset against half the label height |
above | 0.65 | Left-edge offset against half the wider box |
| anything else | 0.60 | Factor omitted |
Alignment converts an offset to a score with FromAlignment(offset, tolerance) =
1 - |offset|/tolerance, reaching 0 at or beyond the tolerance. Two related helpers exist:
FromEditDistance(editDist, maxLen int) float64 and
FromOverlap(overlapArea, box1Area, box2Area) (intersection over union).
Provenance
Section titled “Provenance”type Provenance struct { Method string // "field.right_of", "field.below", "field.chain", "schema.checkbox", ... Anchor string MatchedText string Page int64 Distance float64 // px between label box and value box LabelEditDistance float64 LabelBox geometry.Box ValueBox geometry.Box Factors []confidence.Factor}Without it a wrong value and a right value look identical to a reviewer. String() renders one
line; FieldResult.Explain() renders the value plus every contributing factor.
Getting a scored value
Section titled “Getting a scored value”The FuzzyTextNear* family returns a bare string and discards every trust signal. The
confidence-carrying counterparts return a query.FieldResult:
FieldNear(label string, dir Direction, maxDistPerWord, maxTotalDist int64, expand float64) query.FieldResultFieldNearCapped(label string, dir Direction, maxDistPerWord, maxTotalDist int64, expand, maxExtent float64) query.FieldResultFieldNearChain(chain string, valueDir Direction, maxDistPerWord, maxTotalDist int64, expand float64) query.FieldResultFieldNearChainCapped(chain string, valueDir Direction, maxDistPerWord, maxTotalDist int64, expand, maxExtent float64) query.FieldResultAnchor matches are tried best-edit-distance-first and the first match that actually yields a value
wins, so a bare mention of the label cannot shadow the real labelled occurrence. When the label is
not found you still get a FieldResult — empty, Found false, carrying Method and Anchor in
provenance, and therefore still flagged for review.
amt := doc.FieldNearCapped("Loan Amount", document.DirRight, 1, 2, 0.0, 200.0) // value OCR 0.98rate := doc.FieldNearCapped("Interest Rate", document.DirRight, 1, 2, 0.0, 200.0) // value OCR 0.50// amt.Confidence() >= 0.85, and rate.Confidence() < amt.Confidence()// amt.Provenance().Method == "field.right_of"
const threshold = 0.90amt.NeedsReview(threshold) // falserate.NeedsReview(threshold) // truedoc.FieldNearCapped("Nonexistent Label", document.DirRight, 1, 2, 0.0, 200.0).NeedsReview(threshold) // trueA rule spells the same call bbox.Get("page").FieldNearCapped("Loan Amount", bbox.Dir("right"), 1, 2, 0.0, 200.0),
under the CLI and on the service alike — the direction factory has to be inline and every literal has
to match its parameter type. See
Rules over documents.
Chain extraction retags provenance to field.chain. Intermediate hop edit distances are not
surfaced, so the label factor reflects only the final anchor:
term := doc.FieldNearChainCapped("Loan Information<below>|Loan Term", document.DirRight, 1, 2, 0.0, 200.0)// term.AsText() == "30 years"; term.Provenance().Method == "field.chain"| Accessor | Returns |
|---|---|
Confidence() | float64 in [0,1] |
ConfidenceScore() | Full confidence.Score with factors |
ConfidencePct() | Confidence() * 100 |
NeedsReview(threshold) | True if not found or below threshold |
IsConfident(threshold) | Found and at or above threshold |
Provenance() | The Provenance record |
Explain() | Multi-line value, confidence and per-factor breakdown |
Value blocks are trimmed before scoring. A right-of read keeps the contiguous same-line run and
breaks at the first gap wider than 3.5× the median word gap; a below read keeps only the line
nearest the label, then gap-trims left to right. Surviving blocks are unioned into one block whose
confidence is the minimum OCR confidence across its words — one bad word lowers trust — and
anything after a column-separator artifact is cut. A scored value is therefore often shorter than
the string the equivalent FuzzyTextNear* call returns; the bare-string path deliberately keeps its
wider capture.
When no explicit cap is given, the search extent is max(labelWidth × 2.5, labelHeight × 2). That
same number is the denominator of the proximity factor, so capping the search also changes the
score.
Aggregating and shaping scores
Section titled “Aggregating and shaping scores”| Group | Functions |
|---|---|
| Aggregate | Average, WeightedAverage(scores, weights), Min, Max, Geometric, Harmonic |
| Shape | Penalize(p) → v*(1-p), PenalizeIf, Boost(b) → v + (1-v)*b, BoostIf, AtLeast(t), Cap(max), Floor(min) |
| Classify | IsHigh (≥ 0.8), IsMedium (≥ 0.5 and < 0.8), IsLow (< 0.5), Level(), Percent() |
Level() returns VeryHigh at ≥ 0.9, High at ≥ 0.8, Medium at ≥ 0.6, Low at ≥ 0.4, else
VeryLow.
confidence.Report ({Overall, Components map[string]Score, Issues, Suggestions}) collects a
document-level rollup with Add, AddIssue, AddSuggestion, Compute() and Summary().
Compute() is a plain unweighted average over an unordered map.
Thresholding: calibrate, do not guess
Section titled “Thresholding: calibrate, do not guess”A raw score of 0.8 does not mean “80% likely correct”. It is a weighted average of heuristic
factors, and its relationship to actual correctness depends on your documents, your OCR engine and
your schema. The library says so in its own package documentation, and NeedsReview’s contract is
explicit that the threshold should come from calibration.
The calibration package turns labelled outcomes into a defensible operating point:
| Function | Signature | Purpose |
|---|---|---|
Fit | Fit(samples []Sample) *Calibration | Isotonic regression (Pool-Adjacent-Violators) fitting raw score → empirical probability |
Apply | (c *Calibration) Apply(raw float64) float64 | Piecewise-linear interpolation between knots; holds the end value outside the fitted range |
SampleCount | (c *Calibration) SampleCount() int64 | Size of the fit |
Reliability | Reliability(samples []Sample, bins int) []ReliabilityBin | Equal-width bands over [0,1] (10 when bins <= 0), empty bands omitted |
ECE | ECE(samples []Sample, bins int) float64 | Sample-weighted mean absolute gap between mean predicted and empirical; 0 is perfect |
PrecisionCoverageCurve | PrecisionCoverageCurve(samples []Sample, steps int) []ThresholdStat | Sweeps the threshold 0..1 (20 steps by default, returns steps+1 points) |
ThresholdForPrecision | ThresholdForPrecision(samples []Sample, targetPrecision float64, minAccepted int64) (ThresholdStat, bool) | Lowest threshold meeting the precision floor — that is, maximum coverage |
CurveReport | CurveReport(curve []ThresholdStat) string | Renders the curve as a text table |
A Sample is {Predicted float64; Correct bool}. A ThresholdStat is
{Threshold, Coverage, Precision, Accepted, AcceptedWrong, ReviewedCount} — AcceptedWrong is the
count that matters, the values auto-accepted but actually wrong.
Two properties of the fit are worth knowing when you read its numbers. Isotonic regression repairs a
non-monotone raw scorer: if raw 0.50 is 75% correct and raw 0.70 is 25% correct, both pool to the
same calibrated value. And an empty sample set produces no calibration at all, where an absent
calibration is the identity map, still clamped to [0,1] — 0.37 stays 0.37, 1.5 becomes 1.0, −1
becomes 0.0.
With nothing accepted, precision is defined as 1.0 — vacuously precise, nothing accepted, nothing
wrong. Always read Accepted alongside Precision, and pass a minAccepted floor to
ThresholdForPrecision so the estimate is not based on a handful of points.
The measurement is: run ExtractFormScored over a sample — from either surface — label the results
against ground truth, and take the lowest threshold that meets your precision target with enough
accepted values behind it. ExtractFormScored(name string) map[string]query.FieldResult returns
every declared property, with unfound ones at Found: false, confidence 0 and NeedsReview
true, so you can measure coverage and not just hits. The number you settle on is what you pass to
NeedsReview.
Special-region detection
Section titled “Special-region detection”Signature, handwriting and barcode detection runs inside the analysis pipeline. DefaultConfig
enables it and the CLI always uses DefaultConfig, so every kis bbox invocation pays for it even
though no subcommand prints a region — and there is no flag to switch it off.
Detection runs during analysis, before any rule sees the document, so the regions are already attached by the time a rule asks for them.
A Region carries Type, Box, Page, Confidence (a full Score), NearLabel, OCRText,
OCRConfidence, AspectRatio, WordDensity and IsIsolated. Types render as Signature,
Handwriting, Barcode1D, QRCode, Barcode2D, Unknown. Filters:
FilterByConfidence(regions, min), FilterByType(regions, type), GetSignatures,
GetHandwriting, GetBarcodes. On the document: HasSignatures(), HasHandwriting(),
HasBarcodes(), GetSignaturesOnPage(page), GetHandwritingOnPage(page),
GetBarcodesOnPage(page), GetSpecialRegionsByConfidence(min), GetHighConfidenceSignatures(),
SignatureCount(), BarcodeCount().
All three detectors are inference over OCR geometry. None of them looks at the page image.
Signatures
Section titled “Signatures”Two strategies. Near one of the signature labels listed below, the detector searches right and below
for words whose OCR confidence is under the threshold or text more than 30% non-alphanumeric; failing
that it looks for a signature line (five or more underscores, or a leading X plus three or more).
Separately it emits isolated low-confidence words in the bottom third of a page with fewer than
three blocks within 50px, at confidence ≥ 0.4.
The anchor list is fixed. Nine anchors ship — signature, sign here, sign:, signed:,
authorized signature, x_, x:, sign below, your signature — matched case-insensitively as
substrings, and neither surface adds to it: there is no config key, flag or request field that
registers a domain anchor. A label like Borrower Signature is still found, because it contains
signature; a label that shares no substring with the nine is not an anchor at all.
Handwriting
Section titled “Handwriting”Four factors over OCR geometry, emitted at score ≥ 0.5 and merged with same-type neighbours within
50px: ocr_range (weight 1.0, peaking at OCR confidence 0.5), height_variance (weight 0.8,
coefficient of variation of word heights), spacing_variance (weight 0.6, added only when the CV of
inter-word gaps is ≥ 0.2) and baseline_variance (weight 0.7, added only when the CV of word
bottoms is ≥ 0.02). It is a wobble detector over OCR output, not a handwriting classifier.
Barcodes
Section titled “Barcodes”OCR cleanup and the matcher
Section titled “OCR cleanup and the matcher”Curly quotes, ligatures, homoglyphs and fullwidth characters all burn edit-distance budget. Cleanup happens at conversion time only:
kis hocr2bbox -i scan.hocr -o scan.bbox --cleankis hocr2bbox -i ./pages -o ./bbox --clean-form -w 8Full operation list, presets and the lossy cases are on OCR ingestion.
Things that are not wired up
Section titled “Things that are not wired up”| Capability | State |
|---|---|
| Vocabulary spell correction | Fully implemented, but document.Config.SpellCheck is never read and no spell checker is constructed during analysis, so nothing runs it on either surface |
Correction.Reason | Documented as known_error / fuzzy_match / pattern; always set to vocabulary_match |
| Calibration and field evaluation | No CLI subcommand and no request field reaches them |
| Barcode / QR detection | Emits phantom regions, decodes nothing |
Vocabulary.Correct also iterates an unordered map and keeps the strictly-smaller distance, so two
vocabulary terms tied at the same OCR-aware distance resolve nondeterministically between runs.
A note on page numbers
Section titled “A note on page numbers”kis bbox loads each input file, appends its pages in argument order and renumbers pages
sequentially from 1, rewriting each word’s page number to match. Reported page numbers are
positions in the argument list, not the page numbers stored inside the files, so shell glob order
determines them.
Continue with
Section titled “Continue with”- Extraction — anchors, schema hints and the field APIs
- Tables — detection, headers and master-detail
- OCR ingestion — conversion and text cleanup
- Rules over documents — calling these from a rule
- Command line — the full
kis bboxsurface