ocr_stabilizer 3.0.0
ocr_stabilizer: ^3.0.0 copied to clipboard
Real-time OCR overlay stabilization engine — drift correction, spatial indexing, block tracking, CJK-aware paragraph grouping. Pure Dart; Flutter-ready.
3.0.0 - 2026-09-11 #
The adoption release (#145): the same engine, a surface a stranger can pick up in an hour. Breaking; each entry carries its migration.
Changed #
-
StabilizerConfigreplaces the engine's twelve lever parameters (#149).StabilizationEngine(merger:, config: StabilizerConfig(...))groups the levers by stage —matching(band fallback),merge(position model),stepResponse(mode, snap multiplier,CoherentShiftConfig),retention,diagnostics. The two calibration-dependent coherent-shift levers moved toCoherentShiftConfig.experimental(ExperimentalCoherentShiftOptions(floorPx:, reanchorMinBlocks:)).StabilizerConfig()reproduces the 2.6.x defaults bit for bit (pinned bytest/stabilizer_config_test.dart); the engine's public getters (engine.coherentShiftMinBlocks…) still report the effective values and keep each lever's measured history. Every committed replay stream is byte-identical. Migration:2.6.x constructor parameter 3.0 bandFallback:config: StabilizerConfig(matching: MatchingConfig(bandFallback: …))positionMergeModel:merge: MergeConfig(positionModel: …)stepResponse:stepResponse: StepResponseConfig(mode: …)snapThresholdMultiplier:stepResponse: StepResponseConfig(snapThresholdMultiplier: …)coherentShiftMinBlocks:/MinShare/Tolerance/AdoptAgreeingstepResponse: StepResponseConfig(coherentShift: CoherentShiftConfig(minBlocks: …, minShare: …, tolerance: …, adoptAgreeing: …))coherentShiftFloorPx:/coherentShiftReanchorMinBlocks:… CoherentShiftConfig(experimental: ExperimentalCoherentShiftOptions(floorPx: …, reanchorMinBlocks: …))missedFrameRetention:retention: RetentionConfig(missedFrames: …)transformEstimateMinPairs:diagnostics: DiagnosticsConfig(transformEstimateMinPairs: …) -
CarouselVotesreplaces the{-1: 1}phantom-vote sentinel (#148).ObservableBlock.carouselIdVotes: Map<int, int>is nowcarouselVotes: CarouselVotes, andMergeResult.updatedCarouselIdVotesisupdatedCarouselVotes. A freshly constructed block carriesCarouselVotes.none()— no vote at all — so the engine no longer has to recognise and clear a phantom-1entry on the first real carousel observation, and a consumer's block type no longer has to default to it. The engine advances the histogram withrecord(hzScrollerIndex);hasObservedCarouselsays whether any observation placed the block in a horizontal scroller. Vote counts differ from 2.x in one way: a block's own non-carousel construction is no longer a vote, so a block observed twice outside any carousel now tallies{-1: 1}where 2.x tallied{-1: 2}. Nothing in the engine or the replay reports reads the counts; every committed replay stream is byte-identical. The replay loader maps a recorded lone{-1: 1}tonone(). Migration:2.6.x 3.0 Map<int, int> get carouselIdVotesCarouselVotes get carouselVotescarouselIdVotes: const {-1: 1}(default)carouselVotes: const CarouselVotes.none()carouselIdVotes: {hzScrollerIndex: 1}(own context as first vote)carouselVotes: CarouselVotes.seeded(hzScrollerIndex)carouselIdVotes: merge.updatedCarouselIdVotescarouselVotes: merge.updatedCarouselVotesreading the histogram block.carouselVotes.votes -
CoordinateContextreplaces the eight coordinate getters (#147).TrackedBlocknow declares onecoordinatesgetter instead ofisViewportRelative,isInnerScrollerChild,innerScrollerTop,isHorizontalScrollChild,containerId,scrollContext,isFromStickyElementandstickyFallback(14 getters → 7). The sealed type has three shapes —CoordinateContext.page(scroll:),.innerScroller(top:, containerId:, scroll:),.viewport(stickyFallback:)— so the combinations the engine never expected (a container id without an inner scroller, a carousel child without a carousel index, an inner-scroller top on a page block, sticky without viewport, viewport + inner scroller) are unrepresentable; the old constructor invariant and the spatial index's assert are gone. The eight names survive as derived views on every block (TrackedBlockCoordinateViews) and onBlockMeta, so read sites are unchanged.CoordinateContext.fromFlags(...)adapts a consumer that still holds flat flags and throwsArgumentErroron an inexpressible combination.BlockMetatakescoordinatesin place of its seven coordinate fields.DefaultTrackedBlock.copyWithtakes a wholecoordinatesframe; thecontainerId: nullclearing sentinel (#47) is gone with the need for it. One derived value differs from 2.x: a viewport-relative block'sscrollContextis alwaysScrollContext.none(the classifier already zeroed its offsets; its carousel index only fed the carousel-vote histogram, which nothing reads). Every committed replay stream is byte-identical (all 34,449 recorded blocks are page blocks). Migration:2.6.x 3.0 implement the eight getters implement CoordinateContext get coordinates(build it withCoordinateContext.fromFlags(...)from existing flat fields, or one of the three constructors)DefaultTrackedBlock(isViewportRelative: true, ...)DefaultTrackedBlock(coordinates: const CoordinateContext.viewport(), ...)DefaultTrackedBlock(isInnerScrollerChild: true, innerScrollerTop: t, containerId: id, scrollContext: sc)coordinates: CoordinateContext.innerScroller(top: t, containerId: id, scroll: sc)DefaultTrackedBlock(isHorizontalScrollChild: true, scrollContext: ScrollContext(hzScrollerIndex: i))coordinates: CoordinateContext.page(scroll: ScrollContext(hzScrollerIndex: i))block.copyWith(isInnerScrollerChild: false, containerId: null)block.copyWith(coordinates: const CoordinateContext.page())BlockMeta(isViewportRelative:, isInnerScrollerChild:, innerScrollerTop:, containerId:, captureContext:, isFromStickyElement:, stickyFallback:, ...)BlockMeta(coordinates: ..., positionConfidence:, textConfidence:)reading block.isViewportRelativeetc.unchanged (derived views) -
ObservationandTrackname the two halves of a block (#146).TrackedBlock<T>is nowObservation<T>— the 7 getters a consumer supplies per capture — andObservableBlock<T>isTrack<T>— an observation plus the 8 state getters the engine accumulates. Member sets,DefaultTrackedBlock,BlockMergerand the engine's generics are unchanged apart from the names (StabilizationEngine<T extends Track<P>, P>); the coordinate-views extension isObservationCoordinateViews. The library files moved with them (src/observation.dart,src/track.dart). The engine-owned track wrapper the issue sketched (Track<O>holding the consumer's observation) was evaluated and not adopted: the engine never reads state from a fresh block, so there is no compile-time gain to buy, and it would have re-homed every consumer read ofobservationCount/isProvisionalfor no adoption gain now thatDefaultTrackedBlock+StabilizerConfigmake the quick start four arguments. Migration:2.6.x 3.0 implements TrackedBlock<P>implements Observation<P>implements ObservableBlock<P>implements Track<P>import 'package:ocr_stabilizer/src/tracked_block.dart'.../src/observation.dart(or the barrel)import 'package:ocr_stabilizer/src/observable_block.dart'.../src/track.dart(or the barrel)TrackedBlockCoordinateViewsObservationCoordinateViews
2.6.1 - 2026-09-11 #
Changed #
- README: quick start first (#144). The README now opens with the
install snippet and a working example; the timing model, band fallback,
the
coherentShiftFloorPxrecipe, observing the engine's decisions, the full API tables, the design decisions and the per-version "what's new" narrative moved todoc/pages (content unchanged, links rewritten). No code change.
Fixed #
SpatialBlockIndex.blocksInRegionnow dedups by object identity (#142).allBlocksandcandidatesalways did;blocksInRegionused value equality, so a block type with value equality (an Equatable, the common Flutter case) had two distinct equal-valued instances collapsed to one by this query alone. A consumer counting rivals in a region saw one where there were two. Pinned by a two-equal-instances test across all three queries.
2.6.0 - 2026-09-02 #
Added #
- A similarity-transform estimate on every result (#135). New
StabilizationResult.transformEstimate(TransformEstimate): the least-squares isotropic scale + translation over the capture's eligible matched pairs (the coherent-shift detector's eligibility, collected in the real match loop so it exists under everyStepResponse), after one 3x-median trim, reportingscale,translation,fixedPoint,pairCount,rejectedPairs,residualPx,spanPxandlargestGapShare(the share of the pairs' extent taken by the largest gap between neighbouring cached centres — near 1 the pairs form two clusters, which a step and a zoom fit equally well, so the estimate cannot tell them apart);nullundertransformEstimateMinPairs(new engine knob, default and floor 3: two pairs fit any similarity exactly) pairs. Observed, never applied — the merge keeps its no-zoom model (contract U9, now guaranteed as G11). Evidence: a new zoom corpus (doc/replay/validation/2026-09-zoom/, from the dynamic-reflow generator's new--zoom K): a 1.25x and a 0.8x pure zoom read 1.249 / 0.800 at the event capture with residuals under 4 px over 8 pairs (the trim is what makes the zoom-out readable: untrimmed it read 1.04 with a 179 px residual through two look-alike mismatches), the rewrapping zooms read as the identity reset, and over all 94 non-zoom streams in the repository no capture with six or more pairs and a residual under 10 px exceeds |scale − 1| = 0.010. The entry states the reading rule — deviation= 0.10, residual <= 10 px, gap share <= 0.5, pairs >= 6 — and its margins, pinned by
test/replay/experiment_doc_zoom_tables_test.dart;dart tool/replay/replay.dart transform-report <stream>.jsonlprints a stream's per-capture estimates. - Seed-variance corpus for the dynamic-reflow evidence (#136). Seven
new configurations — three further seeds, each with a fresh-noise
repetition, plus a fresh-noise repetition of the published seed — of
the eleven Tesseract streams, committed under
doc/replay/validation/2026-08-dynamic-reflow/variants/<config>/(--perturb-seedon both generators,variants/gen_seed_suite.py,variants/variance_report.py; each stream's meta note stamps exactly the generator flags that regenerate it), and a "Variance across seeds and repetitions" entry in that corpus's EXPERIMENT.md, pinned cell by cell bytest/replay/experiment_doc_variance_tables_test.dart(the doc-table helpers moved totest/replay/experiment_doc_support.dart). Findings: the controls report 0 step events under every lever on all 8 configurations andcoherentShiftpasses the same 4 of 7 steps on every one; thecoherentShiftFloorPxwindow (377, 406] is the published streams' (a seed-94 scroll ceiling, a seed-93 slab bound) — on the other three synthetic pages the 390 px example is a safe no-op, and no single floor separates the slab from the controls on every page; the adopt lever's 150 px result is one configuration in eight (no plan forms on the other seven, where the lever is byte-identical to plaincoherentShift). The README calibration recipe and the 2.4.0 What's-new are qualified accordingly. Also corrected: the #116 entry's "this environment does not reproduce the committed streams" note was a line-ending artefact — the streams regenerate byte for byte. No code change.
Fixed #
- One license, declared once (#137). The root
LICENSEis MIT — what pub.flutter-io.cn and GitHub display — while every source header carriedSPDX-License-Identifier: BSD-3-Clause, so a license scanner and a human reader saw different licenses. All 48 Dart headers now sayMIT, the 81 Dart files that had no header at all gained the same one, andtest/license_header_test.dartderives the expected identifier fromLICENSE's first line and checks every Dart file in the repository against it (a walk from the root that skips only tool caches and build output — a new source root is covered the day it appears), so the two cannot drift apart again. No code change.
2.5.0 - 2026-09-01 #
Added #
StabilizationResult.coherentShift— the capture-level coherent-shift event (CoherentShiftEvent: the decided translation, how many merges applied it, how many of those were adopted under-gate pairs, and which path decided it —CoherentShiftSource.quorum/floor/reanchor). Before this a consumer could observe a shift only per block, throughMergeResult.stepResponseAppliedin its own merger, and never the vector or the deciding path. Counted at the merges that actually applied the translation — read from each merge's own step response, not from plan membership, so a second fresh block reaching a plan member through a step-response-ineligible path (a carousel child, a band admission) is not counted;nullon every capture where no plan was decided (every control capture; always underdamp/snap). Value equality. Contract G10.MergeOutput.stepResponseApplied— the step response a merge applied (nullfor damp, the freeze path, a nested-fragment confirmation, and a band admission), so a directengine.merge()caller sees the same valuestabilize()counts.StabilizationResult.identityTurnover— the per-capture identity census (IdentityTurnover:merged/admitted/retained/dropped, plusfreshandadmittedShare; value equality;IdentityTurnover.noneis the default on a hand-built result AND the canonical instance every all-zero census resolves to). A highadmittedSharewith cached identities left unmatched and nocoherentShiftis the rewrap shape the dynamic-reflow entry measured (23 of 30 admitted on the rewrap frame), now detectable without reverse-engineeringstableBlocks. README: "Observing the engine's decisions" carries the recipe. Contract G10.- Contract U9 — no scale or zoom model (
doc/CONTRACT.md, README "Design decisions and known limits"): stated explicitly instead of left to silence — a zoom that keeps the line texts is absorbed as per-block displacement (no shift, clean census; pinned by the pure-zoom test), a zoom that rewraps takes the identity-reset path. A transform estimate abovecoherentShiftis tracked as #135, gated on a zoom corpus that does not yet exist.
Internal #
- The engine's coherent-shift plan record now carries its deciding source
and adopted subset (private). Numerics unchanged: every committed
.ab.jsonreport and experiment-document table re-verifies byte-for-byte (G6), and the demo-GIF provenance pin holds. - Tracked, not fixed here: a second seed + repetitions for the
dynamic-reflow corpus (#136); the
LICENSE(MIT) vs SPDX header (BSD-3-Clause) mismatch (#137 — an owner's call).
2.4.1 - 2026-09-01 #
Internal #
- CI is fully green again — formatter compliance restored. The
panaCI job scores formatter compliance (10 of its 160 points) and gates on a perfect score;lib/src/stabilization_engine.darthad drifted fromdart formatoutput at the 2026-08-30 #119 item 2 merge, so the pana job had been red since then (bothtestlegs stayed green throughout). The file is re-wrapped to the stable formatter's output — whitespace and line-wrapping only, except one two-lineifthat gained braces to satisfycurly_braces_in_flow_control_structures. No behavior change; the full 822-test suite is byte-for-byte the same set, all green. - CI: formatter gate on the fast leg + real enforcement. To be exact
about the failure mode (this entry's first draft got it wrong; caught
in review): the red pana check was already visible PRE-merge — the
workflow runs on every pull request — and both offending merges went
in over a red X, because no check was required. Two changes: the
testjob's stable leg now runsdart format --output=none --set-exit-if-changed lib/— pana's effective scope, established empirically in this release's own CI runs: onlylib/files move the pana score here, and a wider.scope fails ontest//tool/files the score ignores (stable leg only, since the 3.3 floor leg's older formatter disagrees). Andmainnow carries required-status-checks branch protection (testboth legs +pana), which is the part that actually prevents a merge-over-red recurrence.
2.4.0 - 2026-09-01 #
Changed #
coherentShiftAdoptAgreeingis now the default (#119 item 2). Once a coherent shift IS decided (by the quorum or a configured fallback), the matched pairs that sat under their own "moved" gate but agree with the decided translation now follow it by default, instead of lagging by the damped fraction. Evidence (the 17-stream A/B, candidate-3 section ofdoc/replay/validation/2026-08-dynamic-reflow/EXPERIMENT.md): 16 streams byte-identical — every control included, because a capture where no shift is decided is untouched by construction — and the one affected stream strictly better (pushdown-150move-capture lag 68.3 -> 6.0 px, +3/+5 lags 54.7/45.2 -> 19.7/20.5 px, identity at +2/+5 0.821/0.750 -> 0.929/0.929, 15 additional merges retained). The demo-GIF provenance pin re-verified the demo corpus byte-identical under the new default in the release run. PasscoherentShiftAdoptAgreeing: falseto reproduce 2.3.x numerics bit-for-bit — the same escape-hatch convention as 2.3.0'sstepResponse: StepResponse.damp.- The 2.x contract is now one page —
doc/CONTRACT.md: the guarantees, the intentionally-unsupported cases (each with its tracking issue), and the consumer-configurable behaviors, each citing its enforcing test or validation entry. The README links it from the top, states the engine/grouper boundary at theParagraphGroupersection (#101 — the engine does not know what a paragraph is; consumers decide the unit of tracking), and adds a calibration recipe forcoherentShiftFloorPx.
Added #
StabilizationEngine.coherentShiftFloorPx(#119) — an opt-in absolute-pixel floor that closesStepResponse.coherentShift's large-slab blind spot. When a single-frame layout step is big enough that most lines leave the viewport, too few movers survive the primary spatial match for thecoherentShiftMinBlocks/coherentShiftMinSharequorum to see (measured: on the 600 px validation stream the move capture leaves exactly ONE matched mover), so the whole capture fell through to damp. A moved pair clearing this absolute floor is now admitted on its own magnitude, bypassing both count gates, provided the floor-qualified movers agree in direction AND cluster within the quorum's own tolerance (the largest such cluster is re-anchored by its own median; a floor-qualified mover outside it stays on damp, so no member is ever pushed past its own observation) — and only where the ordinary quorum already declined, so captures the majority vote handles are untouched.null(the default) keeps the floor OFF, reproducing the un-floored quorum bit-for-bit (this option in isolation; the release's own adopt-agreeing default flip is what changes numerics — see Changed above). Evidence: the 17-stream A/B re-run at 390 px — 0 step events on all 10 controls, 0.000 px regression on every stream, and the 600 px stream's lag at the move cut 30.7 -> 1.4 px. The floor must be calibrated to the consumer's own capture cadence; a height-relative multiplier provably cannot do this job (on the corpus a scroll control reaches 3.63x its own scale while the real slab is only 2.64x). Seedoc/replay/validation/2026-08-dynamic-reflow/EXPERIMENT.md's "closing the large-slab blind spot" section.StabilizationEngine.coherentShiftReanchorMinBlocks(#119) — an opt-in relaxation of the same quorum on the COUNT axis instead (clustering unchanged, share gate dropped, the winning cluster re-anchored by its own median).null(the default) keeps this fallback OFF, reproducing the unrelaxed quorum bit-for-bit (this option in isolation — see the Changed entry for the release's default flip). Documented as not recommended and measured as such: only a count of 1 reaches the large-slab case, and a single mover is equally what ordinary scroll and OCR jitter produce, so it false-fires on 4 of 10 control streams; any higher count leaves the blind spot open. Kept for consumers whose own corpus has large slabs that do leave several matched movers behind.StabilizationEngine.coherentShiftAdoptAgreeing(#119 item 2) — an opt-in widening of who FOLLOWS a coherent shift, not of who may vote. The "moved" gate is 3x each block's own height, so a 150 px slab step carries a 36 px line past its gate but not a 60 px line past its 180 px gate: on the 150 px validation stream three short movers vote a valid shift while 13 taller pairs that made the same step stay under their gate and damp, lagging by the damped fraction for the rest of the stream. With the lever on, once a shift is decided (by the quorum, the floor or the re-anchor), every eligible under-gate pair whose displacement is within the quorum's own tolerance of the DECIDED translation joins the group and is merged with it; a pair that merely jittered stays on damp.falsereproduces 2.3.x numerics bit-for-bit (the lever shipped default-ON in this release — see Changed above). Evidence: the 17-stream A/B — 16 streams byte-identical (every control, and every step stream where no group forms or every pair already votes); onpushdown-150the move-capture lag drops 68.3 -> 6.0 px, the +3 / +5 lags 54.7 / 45.2 -> 19.7 / 20.5 px, identity at +2 / +5 rises 0.821 / 0.750 -> 0.929 / 0.929 and 15 more merges land (pairs that used to fall out of tracking stay tracked). The 50 px step stays out of reach — no pair ever clears its own gate there, so there is nothing to follow.--coherent-adoptadds anagreementCoherentAdoptarm toab-report. See the candidate-3 section ofdoc/replay/validation/2026-08-dynamic-reflow/EXPERIMENT.md.
Internal #
- Provenance guard hardening (#125, #127, #128). The dynamic-reflow
originals
pushdown.ab.json/rewrap.ab.jsonwere regenerated from their committed streams and carry the full four-arm, eight-field schema (theirlegacy/agreementWeightednumerics are unchanged), soab_report_committed_equivalence_test.dartnow holds every committed report to the same full-schema equality with no lenient branch left. The guard's hand-written field list is checked against a liveabReport()arm (a field added to the report and not to the list goes red instead of silently un-pinning it), and the guard's list of default arms is pinned the same way (an arm promoted to a default and not listed there goes red instead of being compared against nothing). A newexperiment_doc_tables_test.dartparses the result tables of the fourEXPERIMENT.mddocuments — its scope note names the tables it parses and the ones it leaves out — and checks each per-arm cell against the committed.ab.json, or a live replay for the--coherent-floor/--coherent-reanchor/--coherent-adoptsweeps, at the document's display precision: the report value rendered to the cell's decimals must equal the cell, so a regeneration that moves a figure goes red instead of leaving the prose stale (two such cells had rotted unnoticed before).
2.3.0 - 2026-08-29 #
Changed #
StabilizationEngine's defaultStepResponseis nowcoherentShift(#116). The engine now re-anchors a batch-wide coherent shift by default, instead of damping a genuine layout step as jitter;StepResponse.damprestores the previous (2.2.0 and earlier) numerics exactly, andStepResponse.snapremains available. Evidence: a 17-stream A/B (11 committed + 6 generated push-down/ push-up variants), re-derived independently from rawab-reportoutput over all 17 streams against theagreementWeighted(damp) baseline —coherentShift14/17 vssnap11/17, with zero false-triggered step events on any control stream (snapfalse-triggered on 4 of 10). Two blind spots are documented, not regressions: a 600 px single-frame slab (no group meets the default quorum, so the merge falls through to damp's numbers unchanged) and slabs of 50–150 px (inside or near the 3×-height jitter allowance, so the cut is null or partial) — tracked as #119. Full table:doc/replay/validation/2026-08-dynamic-reflow/EXPERIMENT.md's "Step response A/B" section. The #120 review fan-out then hardened_detectCoherentShift's clustering (deterministic ordering) and its member drift snapshot (frozen at vote time, no longer re-read live mid-capture); re-running the 17-stream A/B afterward reproduces the identical 14/17 vs 11/17 verdict and every PASS/FAIL cell, with three streams'agreementCoherentlag numbers re-derived by ≤0.1 px and their.ab.jsonregenerated to match — see the EXPERIMENT.md "Re-verified post-#120 review" note.
2.2.0 - 2026-08-29 #
Added #
-
Nested re-observation (#112). An OCR engine's grouping can flip between frames: the same paragraph comes back as one paragraph box in one capture and as one of its own lines in the next. The line's text is a fragment of the paragraph's, so the whole-string match fails (17 vs 33 characters scores under 0.70) and the line was admitted as a NEW block — the same text tracked twice, drawn as a box inside a box until retention expired the paragraph (8 of the 23 residual overlap pairs in the 2.1.0 demo). Now, when both the primary and the band path miss, a fresh block at least 80 % of whose own area lies inside a cached, non-provisional block, and whose text scores ≥ 0.70 windowed Levenshtein against that block's text (on ≥ 4 significant characters), is a confirmation of that block: observation count up, geometry, text and votes untouched — a fragment casts no text vote and pulls no position. Fragments are resolved after every full match of the capture, so a paragraph reported together with one of its lines merges once, never twice. One-directional on purpose: a fresh paragraph over an established line stays on the whole-string path. The two bars were measured on the committed on-device stream, not chosen: the host only has to have been seen once (the grouping flips every frame there), and containment is 0.8 because a line box hangs a few px below its paragraph box.
MergeResult.isNestedFragment(additive, default false) tells consumers and tools such a confirmation from a position merge. This runs on the match path, so retention 0 — the default — is affected; every committed.ab.jsonwas regenerated (below). -
StabilizationEngine.updateBucketSizes/SpatialBlockIndex.setBucketSizes(#113). Set the spatial-index bucket sizes directly, with the same re-keying contract asupdateViewport, for consumers whose bucket policy is not the viewport formula (the reference producer switches to 2× the median block height once it has enough blocks) and for the replay rig.updateBucketSizesPINS the sizes: a laterupdateViewportkeeps them (a non-formula policy is not silently reverted by the next rotation) until it is called withresetBucketPolicy: true;StabilizationEngine.bucketsPinnedreports the state. The index now re-keys its own blocks whenever its sizes change (setBucketSizes,updateBucketSizes) — the re-key is no longer a caller obligation. -
Nested re-observation × grouping contradictions. A cached block the grouping detector (#49) flags as split into two or more of the capture's blocks is withheld from nested absorption in that call: the contradiction is handed to the consumer and the fragments enter as new blocks, as before 2.2.0. On a nested confirmation the engine passes the HOST to the merger as
freshtoo, so a merger written to the 2.1 contract (copy pass-through fields fromfresh) cannot overwrite the paragraph's with the line's.freeze-reportcountsnestedFragmentMergesbesidetotalMergesand leaves them out offrozenShare's denominator (they can never be freezes); oneBucketPolicyAppliernow drives bothreplay()anddump_frames.dart. -
The replay rig models the consumer's bucket policy (#113). Capture schema v1 gains an additive
meta.bk=[bucketW, bucketH]— the buckets the consumer was actually using, written whenever they change and carried onto every laterobs.replay(),ab-report,freeze-reportanddump_frames.darttake--buckets=auto|formula| median:auto(default) applies the stream'sbkexactly where the producer applied it and falls back to the viewport formula for a stream without it;formulais the 2.1.0 behaviour;medianemulates the reference producer's rule (from the 4th tracked block on, both sides = clamp(2 × median tracked-block height, 80, 220)) from the stream alone. Reports recordinput.bucketPolicyand the sizes each arm applied (bucketsApplied). -
StepResponse(#116, candidate fixes for the push-down-reflow lag). The agreement-weighted position model damps every residual as jitter, including a genuine layout step — an ad/image finishing load pushes every line below it down by a fixed offset in one frame, and the model then draws tracked boxes 130-275px above the real text for several captures.StabilizationEngine(stepResponse: ...)adds two opt-in alternatives to the defaultStepResponse.damp(today's behaviour, unchanged):StepResponse.snapre-anchors a single block outright once its residual exceedssnapThresholdMultiplier(default 1.5) times the block's own agreement scale;StepResponse.coherentShiftlooks for a group of matched pairs in the same capture whose displacement agrees (withincoherentShiftTolerance, default 0.5x the smaller block height) and, once the group clearscoherentShiftMinBlocks(default 3) andcoherentShiftMinShare(default 0.5), applies the group's median displacement as a batch shift before the normal weighted merge runs. Neither is ever applied to a provisional (frozen), nested-fragment, or band-fallback-admission merge, and both are a documented no-op underPositionMergeModel.legacy(which has no residual/scale concept to gate on).MergeResult.stepResponseApplied(additive, default null) tells consumers and replay tooling whichStepResponse, if any, a merge received. The default (StepResponse.damp) reproduces today's numerics exactly — no behaviour changes for an engine that does not passstepResponse.
Changed #
- Reports separate nested confirmations from position merges.
ab-reportarms gainnestedFragmentMerges;mergeCountcounts every merge, the displacement buckets and the well-observed pconf stats run overmergeCount − nestedFragmentMergesonly — a confirmation moves nothing by construction and would have pulled every mean toward 0 without a box having moved. All eight committed.ab.jsonwere regenerated: every displacement mean and count is unchanged; the two on-device ML Kit streams count 3 (dwell, 30→33 merges) and 6 (scroll, 18→24) nested confirmations; the six synthetic streams count none. - Bucket-policy delta, measured (#113). The committed streams predate
bk, so their reports still run on the viewport formula (bucketPolicy: viewportFormulaunderauto).--buckets=medianon the agreement arm: ML Kit dwell buckets ≈ 103 px instead of 80×88 — 36 merges, n1-2 8.71→10.18, n6-10 0.19→4.30 over six merges (legacy 0.79→20.74: the far matches the 2.1.0 note said production geometry loses come back at the consumer's real bucket size); ML Kit scroll 103→171 px, n1-2 5.46→6.24; PaddleOCR scroll 80 px, n1-2 2.12→0.54, n3-5 1.08→0.14; Tesseract scroll 102–150 px, n1-2 2.87→1.30, n3-5 1.08→0.64; the four synthetic dwell streams unchanged. The direction depends on the stream, which is why the field exists — a recorder that writesbksettles it per capture. Each entry carries a dated note. A first stream WITHbk(ML Kit dwell, 2026-08-29 addendum to the on-device entry) applied its own sizes beside the emulation: the consumer's sequence and the rig's median emulation agree on only two of the sizes applied (the consumer re-derives from a block set the rig has already trimmed), and on that still page neither moves a single merge. - Dynamic-reflow replay corpus (#93). Two synthesized Tesseract
scenarios in
doc/replay/validation/2026-08-dynamic-reflow/(plus a unit-of-identity addendum: the same streams replayed as lines and as pre-grouped paragraphs, withtool/replay/pregroup.dart) — an image slab that pushes every line below it down 300 px, and a font swap that re-wraps every line — with the regime each lands in stated and pinned bytest/replay/dynamic_reflow_corpus_test.dart: push-down keeps identity for most shifted lines but the position model damps the 300 px step as jitter, so tracked positions lag the move by 130–275 px for six-plus captures (tracked as #116); re-wrap resets 23 of 30 line identities and the new chains track from the next capture, as it should. - Hero demo GIF re-rendered (captures 0–18,
missedFrameRetention: 2): overlapping tracked-box pairs across the 14 frames drop from 23 to 15, all 15 the producer's scroll-stamp lag placing different text (or a re-split of the same paragraph 23–31 px lower) over an established box. The README caption no longer lists the nested line as a visible overlap.
2.1.0 - 2026-08-29 #
Added #
- Cross-frame supersession under
missedFrameRetention. A cached block that is not matched this capture, but half or more of whose own area ONE fresh block of this capture covers, is evicted instead of retained. The bar is measured against the CACHED block's area, so a single line reported inside a retained paragraph does not evict the paragraph; the resolver's per-script NMS threshold applies only where it is stricter (short Latin snippets, 0.65) — it is not reused as-is, because its CJK value (0.35) would let a sliver evict a CJK block that an equal Latin block survives. Blocks from different carousels, and viewport-relative vs page-absolute blocks, never supersede each other; the candidate search spans the fresh block's whole rect, not just the cells around its centre. Before this, the old box stayed in the tracked state for the whole retention window and a consumer drawing fromspatialIndex.allBlockspainted it on top of the new one (the box-on-box overlaps in the 2.0.0 hero GIF). This is a deliberate trade of identity for a clean frame: a wrongly placed fresh block (a lagged scroll stamp) evicts a correct retained one, which re-enters as new. Retention 0 — the default — is untouched: the rule runs only inside the retention branch, so no default-configuration number moves because of it; a consumer that runs its own matching throughmerge()never reaches it. - The replay rig honours the producer viewport. Capture schema v1
gains an additive
meta.vp=[cssWidth, cssHeight];replay(),freeze-report,ab-reportanddump_frames.dartcallupdateViewportwith it — the viewport-derived bucket geometry a consumer configures throughupdateViewportor throughSpatialBlockIndex.updateBucketSizeson an injected index — instead of replaying on the engine's 200 px default buckets.--viewport=WxHoverrides (finite positive values only); with neither, the rig warns on stderr. Reports record the viewport actually applied underinput.viewport. All eight committed streams carryvpand their.ab.jsonwere regenerated: the dwell-only synthetic streams are unchanged; the ML Kit streams lose the cross-neighbourhood matches production geometry never offers (dwell 34→30 merges; n6-10 legacy 20.74→0.79 px); the Tesseract and PaddleOCR scroll streams move by 0.1–1.6 px per bucket. The synthetic corpora writevpfrom theirgen_corpus.py; the two on-device ML Kit streams were recorded before the field existed and carry a value (360×587) read from the recording WebView during the capture session and stamped into the header afterwards — their entry says so, and the recorder-side writer is tracked by the producer's own tracker. The three validation entries carry a dated note.tool/replay/src/replay_session.dartlists what the rig still does not model: a consumer's own matching stage, bucket adaptation beyond the viewport formula,contextualCheck, a consumer-suppliedDriftTracker.
Changed #
- Hero demo GIF re-rendered from the viewport-honouring dump (captures
0–18,
missedFrameRetention: 2): overlapping tracked-box pairs across the 14 frames drop from 32 to 23, counted bydoc/media/count_overlap_pairs.py(any two tracked boxes with a positive intersection, per frame). Of the 23 that remain, 8 are a paragraph box with one of its own lines inside it (a grouping flip the rule keeps on purpose) and 14 are the producer's scroll-stamp lag placing different text over an established box, which no engine rule can tell from real new text. test/long_session_replay_test.dart: the text-churn modulus is now 23 (divides the 69-capture pass) and the fixture asserts its own periodicity (period two passes, by parity); the flatness detector compares same-phase passes by equality. Supersession made the population phase-sensitive, which exposed that the old modulus (11) drifted ~3 captures per pass.
2.0.0 - 2026-08-24 #
Breaking #
-
StabilizationEngine.spatialIndexis now a read-onlySpatialIndexView(#96). The historical "known seam" — a public mutable field whoseadd(...)bypassed the confidence-validation guards onstabilize/merge— is closed. Consumers holding only the engine can query, never mutate. Pre-seeding and external eviction go through an index YOU construct and inject; the injector owns mutation (and the guarded-construction responsibility that comes with it).- engine.spatialIndex.add(block); + final index = SpatialBlockIndex<MyBlock>(); + final engine = StabilizationEngine(..., spatialIndex: index); + index.add(block); // mutate YOUR reference; the engine exposes a viewQuery call sites (
allBlocks,blocksInRegion,candidates, bucket getters, cell keys) are unchanged.
Added #
ParagraphGrouper.onMergeDecision(#92) — optional merge-decision diagnostics. Every candidate-vs-paragraph decision and every block drop is reported as aMergeDecisionDiagnosticcarrying the FULL set ofMergeRejectReasons that fired (no short-circuit masking) plus the numbers the guards compared (gap,threshold,xTolerance). Null (the default) is the zero-cost path; grouping output is identical with and without a callback.SpatialIndexView— the read-only query interface implemented bySpatialBlockIndex(#96).
Documentation #
- Confidence scalars are the API contract; the component signals that
shape them are internal and refactorable (#98 decision, recorded in
types/confidence_types.dart).
1.2.0 - 2026-07-29 #
Added #
ParagraphGrouper— CJK-aware grouping ofOcrBlocks into paragraph-level units, with an extensive oracle test suite. Otsu-thresholded gap clustering, adaptive height-proportional thresholds (DPR/font-size invariant), CJK sentence-ending punctuation awareness (。!?… — strict threshold + multi-line block explosion), Tukey IQR height fences (viaIqrOutlier, the same fence utilityBlockClassifieruses), ICDAR aspect-ratio + rune-density noise guards, and inline-peer detection so side-by-side UI elements (tag pills, toolbar items) never merge. Merge caps are constructor knobs:maxParagraphBlocks(default 3) andmaxParagraphRunes(default 200), alongsidelineGapThreshold(10.0) andlineGapMultiplier(0.75).otsusThreshold/otsusThresholdWithFallback— Otsu's method for 1-D bimodal gap distributions with small-sample guards (N<5 → median heuristic, N<10 → max-gap heuristic) and a 20% inter-class-variance floor that rejects unimodal distributions. Accepts input in any order (already-sorted input avoids an internal defensive copy). Used byParagraphGrouper; exported for standalone gap-clustering use (e.g. inline element splitting).
Design notes (pre-release review hardening) #
The pre-release adversarial review confirmed and closed the following in this same release, so none of them ever shipped:
- The noise guard's char-height baseline (median) and the height fence are computed over density-passing blocks only — an OCR artifact cannot inflate the very statistics meant to reject it.
- Inline-peer detection is symmetric (candidate left OR right of the paragraph) and same-row blocks are tie-broken left-to-right, so grouping is deterministic regardless of the order the OCR engine emits blocks.
- The 2×-avg-height hard ceiling clamps the Docstrum/Otsu threshold too, not just the adaptive fallback — no gap distribution can push the merge threshold past it.
otsusThresholdnormalizes input ordering instead of silently returning wrong thresholds on unsorted input.
1.1.0 - 2026-07-24 #
Changed #
agreementWeighted's agreement scale is now per-block (#75):3 ×the existing (tracked) block's own height, replacing the region-median base. Tolerance becomes proportional to the block's own text size — a pooled median gets diluted by small siblings (a caption's height says nothing about how much a paragraph may jitter) and needed a cold-region 16 px default; both defects disappear. Six-capture validation (doc/replay/validation/2026-07-perblock-scale/): established-chain OCR-jitter damping improves ~30-60% with informative confidence (0.62 vs 0.35 mean), a fresh physical-rotation reflow capture shows no lag regression (within 0.12 px of the old base, identical at depth), and every other regime is bit-identical or within noise. On uniform streams the two bases coincide, so the #58 3× calibration transfers unchanged — no pinned numerics moved.legacyis unaffected.- README: extraction pipelines named as a first-line use case alongside rendered overlays.
1.0.2 - 2026-07-24 #
Docs #
- New README "Timing model" section + engine dartdoc stating the latency
contract explicitly: render at first sight, refine on re-sight.
First-sighting blocks are returned in
stableBlockson the call that observed them; observation counts and the chain-depth validation bands (n1-2 … n11+) are per-re-observation refinement stats, never a readiness ladder;wellObservedTextsfires at 3 observations as a caching hint, not a display gate. Previously the "captured at 1-2 Hz" framing plus the deep-band tables could read as "stable after ~11 captures (≈11 s)" — no such warm-up exists.
1.0.1 - 2026-07-24 #
Fixed #
- Anomaly-class diagnostics now reach a wired
debugLoggerin ALL build modes (#78). Chatty lines stay debug-only (tree-shaken elsewhere), but the events a consumer wires a logger precisely to see —DriftTracker's non-finite drift/top input skips (dropped beforedump()or the observation log ever see them) andBlockClassifierService's swallowedpositionLookupcallback throw — were invisible outside debug builds. BothdebugLoggerdocs now state the severity split explicitly.
1.0.0 - 2026-07-24 #
The agreementWeighted position-merge model is now the default (#74),
per the #58 three-regime validation verdict and the final consumer gate:
a paired same-stream tool/replay ab-report on two current consumer
captures (2026-07-24) showed equal young-block tracking (n1-2 mean
0.96 vs 1.04 px), roughly halved established-block displacement (n3-5
0.44 vs 0.87 px; n6-10 0.28 vs 0.51 px), and informative position
confidence (0.92 on a healthy stream) where legacy saturates flat 1.0.
Changed — BREAKING for consumers tuned against 0.x numerics #
StabilizationEnginedefaultpositionMergeModelis nowagreementWeighted. Position confidence is no longer additive-saturating: values below 1.0 are the informative norm, so any consumer threshold or weighting tuned against 0.x confidence values (OverlapResolver.qualityScore's position term, custom cutoffs onpositionConfidence) must be re-validated against a current capture (tool/replayab-report is the supported harness).legacyremains available and unchanged — pinStabilizationEngine(positionMergeModel: PositionMergeModel.legacy)to keep the exact 0.x numerics until you re-validate.- The agreement jitter allowance is calibrated against ML-Kit-shaped
residuals; consumers feeding a different OCR engine should re-run the
scale sweep (
doc/replay/validation/2026-07-scale-sweep/) on their own captures. Capture streams can carry per-capture engine attribution (enginerecords,doc/replay/capture_schema.md) to make such stratification possible.
Fixed #
RobustStats.madOrFallbackfloors its MAD and IQR arms atminSpread(#72) — the> 0adoption sentinels let tiny numeric residue through unfloored (divisor-poisoning class; dormant, zero callers today).
0.9.0 - 2026-07-23 #
Validation release for the opt-in agreementWeighted model (#58 data
arc): production-capture replay across three stream regimes (stable /
reflow / heavy OCR jitter), two numerics fixes, and the replay tooling
that produced the evidence. Default behavior is unchanged — legacy
consumers can upgrade without review.
Changed (opt-in agreementWeighted numerics only) #
- The agreement scale is now a jitter allowance: 3× the regional
median block height (#70, #73). The 0.7.0 drift-margin-derived scale
was removed: median-of-drift is a systematic-offset measure — ~0 under
symmetric jitter and pure numeric residue on stable streams — so it
collapsed position confidence on unmoving blocks (1.0 → 0.34, fixed
by the #70 floor) and was unreachable everywhere else. Separately, the
sweep showed the un-tuned 1× fallback scale let the confidence-anchored
merge weight chase deep-chain jitter at 15.8 px/merge (worse than
legacy). Post-change: deep-chain jitter damps to 3.8 px/merge
(legacy: 11.8) while confidence stays regime-discriminating
(~1.0 stable / 0.85 reflow / 0.35 heavy jitter). Sweep evidence:
doc/replay/validation/2026-07-scale-sweep/. Slated to become the 1.0 default (#74).
Added #
- Replay rig for consumer-captured observation streams (#68):
dart tool/replay/replay.dart <freeze-report|ab-report|live-report> <capture.jsonl>grades captured streams against the engine — freeze semantics (#57), position-model A/B (#58), and consumer-side lifecycle views. JSONL schema contract:doc/replay/capture_schema.md.
Decided #
- Provisional-freeze semantics stay evidence-free (#57, #69, #76): frozen captures accrue no observation count, text votes, or position. Decision + re-armed re-open triggers are codified at the freeze path; the one nonzero-traffic counterfactual observed (noisy-OCR dwell, admit-mode replay) was tail magnitude — 1 chain, 3 freezes, 2 discarded high-confidence votes per ~5-minute session.
0.8.0 - 2026-07-22 #
The package is now pure Dart (#59): no Flutter SDK dependency, usable in server-side Dart (PDF and camera OCR pipelines) and CLI tools as well as Flutter apps. Same behavior, new geometry types — read the migration notes below.
Breaking #
- Geometry types moved off
dart:ui.Rect,Offset, andSizeare now package-owned value types exported from the barrel (lib/src/types/geometry.dart), member-compatible with theirdart:uicounterparts and matching their semantics exactly (strictoverlapson edge-touching rects, negative-sizeintersectfor disjoint rects,Rect.lerpincl. null-scaling branches). Migration:- Code constructing package inputs: change the import — call sites are unchanged.
- Flutter render boundary: convert with
ui.Rect.fromLTRB(r.left, r.top, r.right, r.bottom)and the reverse (copy-paste extensions in the README's Platform Support section).
- Debug logging is opt-in.
BlockClassifierServiceandDriftTrackertake adebugLogger: void Function(String)?constructor parameter (default null = silent) instead of calling Flutter'sdebugPrint. PassdebugLogger: printto restore the previous output. - pubspec surface: the
flutterSDK dependency andflutter: '>=3.19.0'environment constraint are gone; dev-deps aretest+lints(replacingflutter_test+flutter_lints— the effective lint rule set is unchanged,flutter_lintslayered Flutter-widget rules this package never triggered).
Internal #
- CI runs on
dartnatively: latest stable plus a Dart 3.3 floor leg (replacing the Flutter 3.19 floor leg — same floor, expressed in the SDK that now matters). Coverage artifact retained. - All 520 tests pass under plain
dart teston both legs; zero Flutter packages in dependency resolution.
0.7.0 - 2026-07-22 #
Additive release: the opt-in agreementWeighted position-merge
prototype (#58). Default behavior is unchanged — ^0.6.0 consumers can
upgrade without review.
Added #
PositionMergeModelenum andStabilizationEngine(positionMergeModel: ...)(#58). The defaultlegacypreserves 0.x numerics exactly. The opt-inagreementWeightedprototype addresses the audit §1.7 findings:- Merge weight decays with observation count
(
fresh / (existing·n + fresh)): long-observed blocks become positionally sticky — a 6-times-confirmed block barely moves for a single 12px outlier — while young blocks still adapt quickly. - Merged confidence is a running mean of positional agreement
(residual vs the region's drift margin, median-block-height scaled
when no margin exists) instead of the saturating sum: disagreeing
observations now reduce confidence, making
qualityScore's position term informative again for well-observed blocks. Rollout mirrors the band-fallback pattern: ship onlegacy, A/BagreementWeightedagainst your captures, adopt when the numbers hold. Slated to become the 1.0 default pending that validation.
- Merge weight decays with observation count
(
0.6.1 - 2026-07-21 #
Performance release finishing the remaining #55 items. No API changes.
Performance #
- Intra-batch NMS now resolves overlaps against a per-batch spatial grid instead of linearly scanning the whole output per fresh block — ~O(n²)·(drift-margin per pair) becomes O(cells) (#55). Behavioral nuance: the grid applies the same 3×3-neighborhood locality contract the inter-capture matching path already uses, so two blocks whose centers sit more than one bucket apart are no longer compared — only observable for blocks wider than ~2 buckets, which the spatial index documented as out-of-contract in 0.5.1.
stabilize()reuses that batch grid for grouping-contradiction detection instead of building a second throwaway index every capture (#55). The publicdetectGroupingContradictionsnow sizes its temporary index's buckets from the engine's spatial index rather than defaults.DriftTracker.medianDriftForKey/medianBlockHeightForKey/driftMarginForKeycache per-key results, invalidated onaddObservationand every clearing path — previously each call copied and sorted up to 20 samples, several times per block per capture (#55).
Internal #
- CI uploads the lcov coverage report as a build artifact from the latest-stable leg (#60; badge/coverage-service wiring still open there — needs a repo token).
flutter_lintsconstraint comment updated for the Dependabot-widened>=4.0.0 <7.0.0range (#63): 4.x resolves on the Dart 3.3 floor, newer lines elsewhere.- Test count: 513.
0.6.0 - 2026-07-20 #
Audit-driven feature-and-fix release implementing the 0.6.0 roadmap (#46–#56). Pre-1.0, behavioral changes take a minor bump: review the Breaking section before upgrading from 0.5.x.
Breaking #
CssSubmapMembership.spaceKeyFormaps viewport-relative (weight 40) and nested IC+carousel (weight 30) blocks toSpaceKey.unknown()instead ofSpaceKey.normal(...)(#48). Drift observation and correction are now symmetric: aposition:fixedheader no longer receives the page-scroll submap's median correction it never contributed to. Consumers relying on VR blocks being drift-corrected (unlikely — the correction was categorically wrong) must supply a customSubmapMembership.ContradictionEvent's constructor is no longerconstand throwsArgumentErrorwhenevidencehas fewer than 2 entries (#51) — the storage-invariant policy (throw, not assert) now applies here too. Migration: dropconstfrom anyContradictionEventconstructions (engine-produced events are unaffected).- Contradiction detectors skip the viewport-relative boundary (#49):
detectGroupingContradictionsignores VR cached blocks anddetectSplittingContradictionsignores VR fresh blocks. Near scroll offset 0, healthy sticky headers were reported as "subdivided" by unrelated normal blocks and evicted by consumers. - Batch-NMS key lifecycle (#50): dedup keys register only for blocks that survive intra-batch NMS, and an evicted block's key retires with it. Dropped blocks no longer fuzzy-suppress later same-neighborhood blocks. Eviction lookup is identity-based — Equatable-style consumer blocks can no longer cause the wrong value-equal element to be replaced.
- New direct dependency
meta: ^1.11.0; dev-dependencyflutter_lintsmoved^5.0.0→^4.0.0because 5.x requires Dart 3.5 and never actually resolved on this package's declared^3.3.0floor — caught by the new CI floor leg (#56).
Added #
StabilizationEngine.missedFrameRetention(#46): opt-in retention window keeping unmatched cached blocks matchable for N furtherstabilize()calls, so a single OCR miss (glare, occlusion) no longer resets a block's accumulated identity. Default0preserves 0.5.x behavior exactly; retained blocks are never part ofstableBlocks. Thestabilize()index-ownership docs now tell one consistent story.StabilizationEngine.updateViewport(...)(#52): single validated entry point that recomputes the spatial index's adaptive buckets and adopts the same dimensions for dedup keys. ThebucketWidth/bucketHeight/scalesetters now throwArgumentErroron non-finite or non-positive values.DriftTracker.propagationCountFor(spaceKey)— public reader for the counts written viarecordPropagation(#53).TextVoteimplements==/hashCode/toString, matching the package's other value types (#53).BandFallbackStatsInternalis annotated@internal— the analyzer now flags downcast-mutation from outside the package (#53).
Deprecated #
DriftTracker.spaceKeys— use the identicalobservedKeys; removal planned for 1.0 (#53).
Fixed #
DriftTracker.clearKey/clearSpatialRegionnow also clear the matching propagation counts, which previously leaked for the rest of the session (#55).
Performance #
- Text-similarity calls (
isTextSimilar,isTextSimilarWithScores,computeTextSimilarity) extract each string's significant-char list once and feed both metric cores — previously up to 6 extractions per comparison (#55).
Internal #
- CI matrix now tests the declared minimum floor (Flutter 3.19.6) alongside latest stable (#56); Dependabot covers GitHub Actions and pub (#60).
- Band admission ordering caveat and
String.hashCodekey-stability caveat documented (#53). - Test backfill (#54): first dedicated suites for
RobustStats,IqrOutlier,OverlapResolver,BlockKeyGenerator,AbsoluteRect, hierarchy/scroll value types,CssSubmapMembership, andTextVote; real mixed-confidence coverage forcomputeTextConfidence; regression tests for every fix above. Test count: 504 (was 297), verified on both stable and Flutter 3.19.6.
0.5.1 - 2026-07-20 #
Bug-fix release driven by the v0.5.0 package audit
(doc/audit/2026-07-20-package-audit.md in the repository). No API
changes — ^0.5.0 consumers resolve 0.5.1 automatically.
Fixed #
SpatialBlockIndex.candidates()now deduplicates yielded blocks by object identity, matchingallBlocks/blocksInRegion. Previously a dual-indexed IC block reachable from both its page-absolute cell and itsic:cell was yielded twice to an IC query — running the full Levenshtein comparison twice per duplicate and double-ticking theBandFallbackStatsfunnel counters (candidatesConsidered,rejectedSpatial,rejectedTextBand, and in observeOnlybandMatchesIdentified) that consumers are told to read before flipping toadmit.StabilizationEngineprimary matching no longer drops a candidate admitted purely through the Jaccard arm with a Levenshtein score of 0.0 (full character reordering, e.g. a two-character OCR segment swap like北京→京北). The best-candidate scan seeded its comparison at 0.0 with a strict>, so such a match never registered and the observation spawned a duplicate block instead of merging.OcrBlocknow stores a NaNconfidenceasnull(unavailable). The documented clamp could not contain NaN — in IEEE-754,nan.clamp(0.0, 1.0)returns NaN — so NaN leaked to any consumer readingconfidencedirectly. Infinite values still clamp to the range bounds.- Unified the package's two divergent CJK-ideograph definitions into one
shared predicate (
lib/src/internal/cjk_ideographs.dart). The dedup utilities (cjkOnly,cjkFraction, significant-char extraction) omitted CJK Extension B (U+20000–U+2A6DF) while the confidence heuristic included it, so text consisting only of Extension-B ideographs produced an empty significant-char list and could never text-dedup.TextDedupUtilsandisCjkIdeographnow agree.
Documentation #
StabilizationEngine.stabilizedocuments the index-rebuild limitation: blocks not re-observed in a capture leave the spatial index and re-enter as new; app-inserted blocks are dropped at the next call. The cache-merge redesign is tracked for 0.6.0.classifyGroupsdocuments its silent fallbacks (degenerateimageToLayoutScale→ identity CSS-per-px; near-singular container transform → untransformed rect) and the actualpositionLookupthrow contract (caught, neutral stability — previously documented as "must not throw").SpatialBlockIndex.blocksInRegiondocuments the center-cell + 1-cell-margin lookup limit for oversized blocks;remove()documents that cell keys are recomputed from the current rect, so removal after mutation silently no-ops.TextDedupUtils.containmentRatiodocuments the 5,000-rune LCS truncation on the public API (was only on the private helper) and drops a stale internal-caller claim.RobustStats.madOrFallbackscopes its "never zero or negative" guarantee to positiveminSpread.MergeResult.observationCountdocuments the provisional-freeze exception (count passes through unchanged while frozen).
Internal #
.pubignoreadded: internal process docs (doc/superpowers/,doc/audit/) no longer ship in the published package archive.- Full package audit report at
doc/audit/2026-07-20-package-audit.md(repository only). - First dedicated test file for
TextDedupUtils(test/text_dedup_utils_test.dart). Test count: 297 (was 277).
0.5.0 - 2026-05-24 #
Quality-polish release. Additive public-API additions plus a latent
engine bug fix in the band-fallback counter accounting. No breaking
changes from 0.4.x — ^0.5.0 is a safe upgrade.
Added #
BandPredicateException— typed wrapper class for throws raised by a consumer-suppliedBandSpatialPredicate. The engine catches the predicate's error inside_findMatchand rewraps it asBandPredicateException(cause, predicateStackTrace). Predicate failures now surface with a typed shape; no silent swallow. Exposescause,predicateStackTrace,message, and an asserting ctor that rejects double-wrapping (#35).BandFallbackStats.rejectedTextBandcounter ticks at the text-band-miss site inside_findMatch. Makes the band funnel decomposable:rejectedCandidateFloor + rejectedSpatial + rejectedTextBand + bandMatchesIdentified == candidatesConsidered, invariant to admit-mode early-exit (#34).- Library-level dartdoc in
lib/ocr_stabilizer.dartnow enumerates the headline types (StabilizationEngine,BandFallbackConfig,BandFallbackStats,BandPredicateException, block hierarchy, confidence types) plus the recommended adoption flow (off → observeOnly → admit) (#35). - Internal
assertConfidenceRange(field, raw, {prefix})utility atlib/src/internal/confidence_validation.dartcentralises the[0.0, 1.0]finite-double predicate. Adopted at five sites:DefaultTrackedBlockctor,MergeResultctor,StabilizationEngine._assertValidConfidence(called bystabilizeandmerge),PositionConfidence.from,TextConfidence.from. Future tightening happens in one place (#31).
Changed #
BandFallbackStats.matchesAdmittedis now incremented at the resolution-time site (where_findMatchactually returns a band match), not at scan-time. Before this release, the counter ticked as soon as a candidate was locked asbandAdmitted— so when a later primary candidate in the same scan superseded it, the counter overcounted and disagreed with the function's return value. New semantics:matchesAdmittedis exactly "band matches returned by_findMatch", and the documented invariantmatchesAdmitted <= bandMatchesIdentifiedis strengthened by the precedence rule "primary always wins, even if a band candidate was locked first" (#34).MergeResultctor's confidence-range error message wording upgraded from'must be in [0.0, 1.0]'to'must be a finite double in [0.0, 1.0]'to match the message at the engine entry guard andDefaultTrackedBlockctor — a consumer catchingArgumentErrorno longer sees two slightly different stories about the same invariant (#31).
Fixed #
- Library dartdoc no longer references a non-existent
stabilize(fresh, captureRect)signature — the actual signature isstabilize(List<T> freshBlocks). Caught by the comment-analyzer pass on PR #42 (#35). - Privacy scrub: removed 17 references to the downstream consumer's internal project name / file:line paths from spec/plan docs and one source comment (#33).
Internal #
- New CI job: pana scoring on ubuntu-latest with
--exit-code-threshold 0pinned to pana0.23.12. Authoritative pre-publish score check — Windows local pana hits 150/160 due to upstreamdart-lang/dartdocissue #4180 (CRLF offsets in Flutter SDK@docImportfiles), but Linux scoring is 160/160 (#36). - Renamed
docs/→doc/for the Pub layout-convention hint (#37). - Test count: 277 (was 270 in v0.4.0).
0.4.0 - 2026-05-23 #
Added #
BandFallbackModeenum (off|observeOnly|admit) configures the band-relaxed fallback path insideStabilizationEngine._findMatch. Default isoff; switch toobserveOnlyto readBandFallbackStatsbefore committing toadmit. Design and default provenance: (#20).BandFallbackConfigvalue type wraps the band thresholds, candidate observation floor, provisional-capture grant, and spatial confirmation predicate. Constructorasserts on out-of-range values (preserves const-constructibility); engine constructor throwsArgumentErrorfor release-build safety. Primary-path floors (Lev 0.70 / Jaccard 0.80) are engine-owned, not configurable through this type (#20).BandFallbackStatsexposes per-capture counters:primaryMatchesAdmitted,primaryMatchesRejected,candidatesConsidered,rejectedCandidateFloor,rejectedSpatial,bandMatchesIdentified,matchesAdmitted. Read-only public surface; engine mutates via a same-libraryInternalsubclass. Reset viareset(); the engine never resets it automatically (#20).BandSpatialPredicatetypedef mirrorsContextualInvalidationCheck—bool Function(TrackedBlock fresh, TrackedBlock candidate). WhenBandFallbackConfig.spatialConfirmisnull, the engine substitutes a drift-awareoverlapRatio >= 0.80closure (#20).StabilizationEngineconstructor gains abandFallback: BandFallbackConfigparameter (defaults tomode: off— backward compatible) and abandStatsgetter returning the read-only stats view (#20).
Changed #
- Breaking:
StabilizationEngine.stabilize()andStabilizationEngine.merge()now throwArgumentErrorif any observation'spositionConfidence.rawortextConfidence.rawisNaNor outside[0.0, 1.0]. Catches anyTrackedBlockimplementor at the engine entry, closing the documented unchecked-const-Confidence gap (#27). - Breaking:
DefaultTrackedBlockconstructor throwsArgumentErrorwhenpositionConfidence.rawortextConfidence.rawisNaNor outside[0.0, 1.0]. Early-fail at construction with a cleaner stack trace than the engine-entry guard would produce. Consumers going throughPositionConfidence.from()/TextConfidence.from()(validated since #19) are unaffected (#27). StabilizationEngine._findMatchprimary path now usesTextDedupUtils.isTextSimilarWithScores(Lev OR Jaccard) instead ofnormalizedLevenshteinalone. Floors are unchanged (Lev 0.70 / Jaccard 0.80); the metric set widens — character-reordered text with the same significant-character set now matches on the primary path where it previously fell through (#20).
Fixed #
OverlapResolver.qualityScoreno longer silently propagatesNaNinto the NMS comparison. NaN reachingqualityScoreis now a debug-timeAssertionError; release builds skip the check (defended by engine entry validation, above) (#27).StabilizationEngineconstructor now rejectsNaNand±InfinityforBandFallbackConfig.bandLevenshteinFloorandbandJaccardFloorin release builds. The bare range check (value < 0 || value >= floor) evaluatedfalseforNaNunder IEEE 754 and let it bypass the defense; theassertin the const ctor catches it in debug only. Now short-circuits on!isFinitebefore the range check (#20).
0.3.0 #
A breaking release bundling four API changes. Pre-1.0, breaking changes take a minor version bump.
Breaking #
ObservableBlockno longer declaresexclusionHitCount, andDefaultTrackedBlockno longer carries it. The field was inert engine-side — never read or written by merge, dedup, drift correction, or the spatial index. It is consumer-managed state and does not belong on the package's block contract. Migration: a consumer with@override int get exclusionHitCount;on its own block class removes the@overrideannotation — the field stays, it is just no longer an interface member. Consumers that do not need the field drop it entirely. (#23)PositionConfidence.from/TextConfidence.fromnow throwArgumentErroron out-of-range or NaN input. Previously they validated withassertonly, which is stripped in release builds — an invalid confidence passed silently in production. Migration: catchArgumentErrorinstead ofAssertionError; any code that relied on release-mode silent acceptance of an out-of-range value now gets a thrown exception. The primaryconst PositionConfidence(double)/const TextConfidence(double)constructor stays public and unchecked — it is the onlyconst-capable path, kept forconstliteral sentinels. (#26)
Changed #
StabilizationEngine.stabilize()now rebuildsspatialIndexinternally from its returnedstableBlocksbefore returning. The old caller contract (callspatialIndex.rebuild(result.stableBlocks)after everystabilize()) is retired, along with the debug-mode staleness guard. Callers can drop the post-stabilize()rebuild call — a redundant rebuild is harmless, so this is non-breaking. (#24)MergeResult's confidence boundary checks now also reject NaN (NaN fails both< 0and> 1.0, so it previously slipped through). (#26)
Added #
StabilizationEngine.resetDriftPropagation()— clears the engine's regional-drift baseline so a consumer can reset propagation state on a session boundary (page navigation, context reset) without a stale baseline triggering a spurious correction. (#25)
0.2.2 #
Added #
- CI: GitHub Actions workflow running
flutter analyze+flutter teston push and PR (#15). CONTRIBUTING.mddocumenting dev setup, conventions, and release flow (#16).
0.2.1 #
Docs + metadata polish. No API change. First pub.flutter-io.cn-shipped release of the v0.2.x line.
Documentation #
- README: install snippet updated to
^0.2.1with a breaking-change pointer back to the 0.2.0 typed-confidence migration. (#14) - README:
TrackedBlock<T>example now lists all 14 getters (was missinginnerScrollerTop,sourceQuality) with a follow-up note pointing integrators atDefaultTrackedBlock<T>orObservableBlock<T>as appropriate. (#14) - README: API Reference tables refreshed for v0.2.x — corrected getter
count, generic on
ObservableBlock<T>, documentedDefaultTrackedBlock<T>,PositionConfidence,TextConfidence, plus the previously-undocumented exports (StabilizationEngine,BlockClassifierService,OverlapResolver,BlockKeyGenerator,MergeResult,StabilizationResult,ClassificationResult,TextVote,IqrOutlier,TextDedupUtils). (#14)
Metadata #
pubspec.yamlgainshomepage:and pub.flutter-io.cntopics:(ocr,overlay,tracking,slam,flutter). (#14).gitignoreexcludes local agentic-scaffolding directory (.ultra/). (#14)
0.2.0 #
Breaking #
TrackedBlock.positionConfidenceandtextConfidencenow return typedPositionConfidence/TextConfidenceextension types (overdouble) instead of rawdouble. Consumers implementingTrackedBlockmust update the getter signatures. The migration path:
Producer sites wrap raw doubles via- final double positionConfidence; - final double textConfidence; + final PositionConfidence positionConfidence; + final TextConfidence textConfidence;.from(value)(range-asserted), or use the.groundTruthsentinel (= 1.0) for deterministic origins. Extension types are zero-cost at runtime.
Added #
PositionConfidence/TextConfidenceextension types inlib/src/types/confidence_types.dart. Range[0.0, 1.0]enforced via.from()factory;.groundTruthstatic const sentinel for DOM/deterministic origins. (#10)DefaultTrackedBlock<T>— concrete reference implementation ofObservableBlock<T>with documented defaults for every required field (notablycarouselIdVotes: {-1: 1}— the engine's phantom-vote sentinel). IncludescopyWithandapplyMerge(MergeResult)convenience. (#5)SpatialBlockIndex.isEmpty— O(1) accessor (was:allBlocks.isEmptyallocated aSet.identity()per call). (#2)StabilizationEngine.stabilize()debug-mode staleness warning when the spatial index appears empty after a non-empty previous call, plus prominent "Caller contract" docstring documenting the consumer's rebuild responsibility. (#2)MergeResultnow throwsArgumentError(not justassert) when invariants are violated, including the confidence-range bypass via the unvalidatedPositionConfidence(double)primary constructor. Asserts strip in release; this guards engine output that flows into consumer caches. (#10)
Changed #
- SDK constraint relaxed from
sdk: ^3.8.1tosdk: ^3.3.0(extension types shipped in Dart 3.3 — the only modern feature this package uses).flutterconstraint pinned to>=3.19.0(the Flutter that bundled Dart 3.3).flutter_lintsdev-dep pinned to^5.0.0to keep dev-deps SDK floor consistent with the package SDK floor. (#3) DriftTrackerrolling windows switched fromList(O(N)removeAt(0)) todart:collection'sQueue(O(1)removeFirst). Type now signals FIFO ring-buffer intent at the declaration site. (#4)DefaultTrackedBlockconstructor throwsArgumentError(not just asserts) when thecontainerId/isInnerScrollerChildinvariant is violated — the reference implementation is state-owning, asserts strip in release. (#5)
Fixed #
SpaceKey.regionIndexreturns 0 as a safe fallback instead of throwingFormatExceptionon malformed keys (forward-compat for externally- constructed or future-format-extension keys). (#1)
Internal #
- New test files:
test/space_key_test.dart,test/confidence_types_test.dart,test/merge_result_test.dart,test/default_tracked_block_test.dart. Test count: 207 (was 180 in v0.1.0).
0.1.0 #
Initial release.
Core #
StabilizationEngine— SAR merge, intra-batch dedup, contradiction detectionDriftTracker— regional drift correction with submap isolationSpatialBlockIndex— grid-cell spatial index for O(cells) overlap queriesOverlapResolver— spatial NMS with language-aware thresholdsBlockKeyGenerator— position+text dedup keys with fuzzy neighbor matchingBlockClassifierService— OCR group classification (fixed/sticky/carousel/IC/normal)
Types #
TrackedBlock<T>/ObservableBlock<P>— block identity interfacesAbsoluteRect— zero-cost coordinate-space safety (extension type)SpaceKey,ContainerId,ScrollContext,StickyFallback— value types
Utilities #
TextDedupUtils— Levenshtein, Jaccard, CJK detectionRobustStats— median, MAD, IQRIqrOutlier— Tukey fence outlier detection