Scalability, measured

What the diff path costs today, where it breaks, and the design that follows from those numbers. Nothing in section 4 is built.

Written 2026-08-10, re-measured the same day at commit c52b986, and two of its tables re-measured again at commit 6b3f874 when the traced region for the pipeline was widened to hold the source document. The two re-measured tables each carry a line saying so, and no table mixes two runs. The handful of numbers from anywhere else — an earlier run, the test suite, docs/tdd.html — say so where they appear. Where something was not measured, the page says so rather than estimating it.

What a re-run of this page found, and where the corrections are. An adversarial reviewer re-ran the script and reproduced every structural figure. Two claims did not survive. Section 5 said the instrument compared change sets by identity and for the index columns it compared counts, so two cells reported agreement that does not hold — corrected in the table there, and the instrument now compares offsets on every row. Section 4 sized a worker with a constant measured on a narrower region than the sentence around it claimed, and erred low, which is the direction that admits a pair the worker cannot hold — there is now a measurement of the whole pipeline call in section 2 and the arithmetic uses it. A third correction is smaller and is marked in section 3.

And then the same reviewer found the same fault one region wider, which is why it is worth saying out loud. The pipeline measurement that fixed the second claim above was itself traced with the source document already built, outside the region, while this page said the figure held the source text. Every pipeline figure here has been re-measured with the document built inside the region: the constant went from 2.55–2.69 bytes per source character to 3.07–3.14, the worker pool from 575 MB to 693 MB, and the admission check's headroom from thirty per cent to eleven. A peak is a peak over a region, and the region is the part a reader cannot see. tests/test_diff_scale.py::test_the_pipeline_peak_is_traced_while_it_holds_the_document now watches the boundary rather than the number.
Why this document exists. A reviewer asked how the system scales to ten thousand concurrent multi-million-word documents and got constraints back — memory, CPU — which is another way of saying nobody had run it on a large document and watched. That was fair. This page is the watching. The order is deliberate: measurement first, then where the measurement says it breaks, then a design. A design written before the measurement would have optimised the wrong function, and section 3 names the function it would have got wrong.
1. The question, answered with the measurement
2. The measured curve
3. Where it breaks, named at the level of a function
4. The design, none of which is built
5. The correctness hazard in partitioning
6. What is not known
7. The accuracy finding this work turned up

1. The question, answered with the measurement

One million words of filing text is about 6.5 million characters, so a version pair of million-word documents is about 13 million characters. That is not an extrapolation here: it is a row in the tables below. The 6,400-passage point is 13.16 million characters. app/diff/engine.py::diff costs 2.599 seconds and 14.26 MB of peak Python heap on it. The call a worker actually makes, app/pipeline.py::ingest_and_diff, costs 2.930 seconds and 38.51 MB — it holds the document it was handed, the passages it segments out of it, the previous version's rows, the diff, the change rows and an audit row per change. Both pairs are one row of one table, measured back to back. The second pair is what the arithmetic below multiplies, and using the first pair was the error a reviewer found in the previous version of this page.

Ten thousand of those:

So the answer to "ten thousand concurrent" is that you never run ten thousand concurrent.

The gap between those two lines is the whole design. A bounded pool of eighteen workers holds 693 MB, which fits on a laptop, and clears the backlog in 27 minutes. An unbounded one asks for 376 GB and is killed by the operating system halfway through, leaving a half-written transaction and no explanation. Nothing in this repository bounds it. There is no pool, no queue and no admission check; the size of the corpus is what keeps it honest, and the size of the corpus is not an argument.

The genuinely binding constraint is not the ten thousand. It is one bad document. Section 2's second table shows that a run of repeated passages is quadratic. Sixteen thousand repeated lines take 15.1 seconds. A million-word filing that is mostly a rate table — say 60-character rows, so about 108,000 of them — extrapolates on that curve to roughly twelve minutes for a single pair: (108,000 / 16,000)² × 15.1 seconds. A filing of 10-character [Reserved] lines, 650,000 of them, comes to nearly seven hours the same way. Those two figures are extrapolations from the measured curve, not measurements, and they are the reason section 4's admission check refuses by name rather than trying.

Two honest qualifications, both of which matter more than the arithmetic. Every multi-worker figure above is single-worker measurement multiplied by ten thousand: nothing on this page was measured under concurrency at all, so it ignores allocator contention, the GIL and a shared database. And the pipeline figure was measured on a pair where one passage in twenty changed; record_event writes an audit row per change, so a wholesale redline pays a term this arithmetic does not carry — see section 3.

2. The measured curve

Reproduce with scripts/bench_diff.py:

.venv/bin/python scripts/bench_diff.py

The corpus comes from an LCG written out in that file rather than from random.Random, so two runs on two machines with two Pythons compare line for line. The passages carry real cumulative offsets, because diff() puts those offsets into every citation and a benchmark that fed it zeros would exercise a shape the product never sees.

Measured on Apple M5 Pro, 18 cores, 64 GB, Darwin 25.5.0 (26.5.1), CPython 3.12.13, commit c52b986. Load average when it started: 3.58, 3.14, 2.53 over one, five and fifteen minutes. One run per point, no repeats, no variance figure. Unless a table says otherwise, its figures came from that run. Two say otherwise: the footprint table below and the split table in section 3 were re-measured at commit 6b3f874, and each carries its own line. Mixing two runs inside one table would be worse than saying which run each table came from. git diff c52b986 6b3f874 -- scripts/bench_diff.py touches the module docstring, footprint_measurement, the partition functions and main. The corpus generators, time_only, peak_only and split_measurement are untouched between the two, so nothing on this page moved because the instrument moved.

The machine was not idle. Other agents were running test suites on it throughout. Every duration here is an upper bound on the same laptop idle. The same script ran three times on this laptop, at load averages of 7.33, 3.58 and 1.93. Every structural figure was identical in all three — the peaks, the change counts, the anchor cut counts, to the byte. The durations spread by up to about 1.4 times, and not in step: the 25,600-passage point moved by 1.38 and the 6,400-passage point by 1.12. That is contention, and it is the reason to read the ratios on this page and to discount the seconds. The per-character cost in the first table is flat at 0.198 to 0.205 microseconds through four doublings and then rises to 0.226 and 0.252. I have not separated contention from a genuine super-linear term, and section 6 keeps that on the list.
Two figures that are not comparable, and a reader will try. Peaks here are tracemalloc's: the Python heap, not the interpreter, not SQLite's page cache, not a C extension. And timing and memory are two separate passes over the same input, because tracing inflates this workload's wall clock by seven to eight times — app/text/normalize.py::normalized_projection allocates per character and tracemalloc charges every allocation. bench_diff.time_only raises rather than trusting anyone to remember that.

All-unique passages: linear

Every passage differs from every other, 120 words each; one passage in twenty is amended on the after side.

passagesMchars, both sidessecondsµs / charpeak MBchanges
4000.820.1630.1980.920
8001.640.3420.2081.840
1,6003.290.6790.2073.680
3,2006.581.3490.2057.1160
6,40013.162.8560.21714.2320
12,80026.315.9420.22628.4640
25,60052.6113.2780.25256.91,280

Linear across sixty-four times the input, with the caveat in the box above about the last two rows. Peak memory is linear with no caveat at all: it works out at 1.13 to 1.20 bytes of peak Python heap per source character at every point, including the largest. Read that constant with the subsection below beside it: it is what diff() allocates on top of passage lists that already exist, which is not what a worker holds.

A run of repeated passages: quadratic

100 unique passages, then a run of N identical [Reserved] lines with one of them amended in the middle. This is not a contrived shape: a tariff sheet with two hundred reserved lines, or a rate table whose rows repeat, is an ordinary filing.

repeated linesMchars, both sidessecondschanges reported
5000.220.0552
1,0000.230.0992
2,0000.250.2722
4,0000.290.9762
8,0000.373.7912
16,0000.5315.1292

Doubling the run approaches quadrupling the time: 1.8x, 2.8x, 3.6x, 3.9x, 4.0x across the five doublings. The early ratios are below four because the linear normalization cost still dominates at 500 lines; by 8,000 the quadratic term owns the call. Note the document sizes: 525,059 characters here cost more than the 52.6 million characters of ordinary prose in the table above, which run in 13.3 seconds. Size in bytes is not the variable; repetition is. Memory was not measured for this family — the cost is in the matcher's comparisons rather than in allocation — and the table says so rather than carrying a figure from the other one.

The real pair

Kentucky PSC 2025-00113, Lane Kollen's direct testimony as filed and as corrected by the filer, segmented the way app/ingestion/ingest.py::_segment segments it.

passagesMchars, both sidessecondsµs / charpeak MBchanges
4,2852.030.4030.1992.3144

1.17 bytes of peak heap per source character, in line with the synthetic constant on a document nobody wrote for this benchmark.

This is not the 0.78 seconds docs/tdd.html quotes for the same pair, and both are right. That figure is the whole pipeline: ingestion, segmentation, the diff, and the passage, change and audit rows written to SQLite. The 0.403 seconds here is diff() alone, in memory, with no session and no writes. Anyone comparing the two is comparing a function with a transaction — and the subsection below now measures the transaction, so the comparison no longer has to be guessed at.

What a worker holds, which is not what the curve above measures

This subsection is a correction. The peaks in the tables above are what diff() allocates on top of passage lists that already exist — tracing starts after the caller has built them, so the documents are outside the traced region by construction. That is the right figure for how the call grows with its input and the wrong figure for sizing a worker, and an earlier version of this page used it for the second. It understated the real call by a factor of 2.6 at the smallest size measured and 2.7 at the largest, in the direction that admits a pair a worker then cannot hold.
And this subsection has been corrected once itself, for the same reason. The first version of it built both source strings before the traced block began, so the document the call was working on sat outside the region while the sentence beside the table said the peak held the source text. The figures below are the re-measurement: the new version's source is joined inside the region, which is what a worker holds. The previous version's source stays outside, because a worker reads the previous version out of the database rather than holding its text, and the benchmark has to load it before it can time anything.

bench_diff.footprint_measurement traces three regions on the same input, each wider than the last. The third is app/pipeline.py::ingest_and_diff against a scratch SQLite file: the new version's source string joined inside the traced region, then segmentation, the previous version's rows read back out of SQLite, the diff, the change rows and an audit row per change. That is the call a worker makes.

passagesMchars, both sidesdiff() alone — MB and bytes/charthe inputs and diff()the whole pipeline calldiff() secondspipeline secondschanges
4000.820.95 MB — 1.211.50 MB — 1.912.46 MB — 3.140.1530.17220
1,6003.293.61 MB — 1.155.83 MB — 1.869.69 MB — 3.090.6160.69480
3,2006.587.16 MB — 1.1411.59 MB — 1.8519.42 MB — 3.101.2411.395160
6,40013.1614.26 MB — 1.1423.13 MB — 1.8438.51 MB — 3.072.5992.930320

This table only: commit 6b3f874, same machine, load average 2.79, 2.53, 2.17 when it started. MB here means 220 bytes. One run per point.

The pipeline constant is flat at 3.07 to 3.14 bytes of peak Python heap per source character across sixteen times the input, which is what makes an admission check arithmetic rather than a guess. Section 4 rounds it up to 3.5 and says why. tests/test_diff_scale.py::test_the_page_never_admits_a_pair_bigger_than_a_worker_can_hold fails if that published number ever falls below what the pipeline measures, and ::test_the_pipeline_peak_is_traced_while_it_holds_the_document fails if the document is ever built outside the region again. That second guard is not an inequality on the bytes, because none would work: moving the join out costs about half a byte per source character and the region has more slack than that, so any threshold that separated the two would be a number tuned to one run. It watches where the allocation happens instead.

Both second columns are measured with tracing off, back to back, in a pass of their own away from the peaks. They are in the same row so that the two can be compared with each other: the page used to set 3.209 seconds for the pipeline against 2.856 for diff() and take the difference for the cost of the transaction, and those two figures came from two runs at two load averages on a laptop this page says was never idle. A ratio between two runs is not a ratio. On this row the pipeline call costs 1.13 times diff() alone at the largest size. docs/tdd.html quotes 0.78 seconds for the pipeline on the real Kentucky pair, which is the same kind of measurement at one size. Three things these seconds do not tell you. The first is small and is the price of the correction above: joining the source document happens inside the timed region, because it has to happen inside the traced one, and a worker is handed its text rather than joining it. The other two would push the figure up. The fixture amends one passage in twenty, so the largest pair here carries 320 changes; record_event writes an audit row and flushes for every one of them, so a pair where most passages changed pays a term this table cannot see. And tracemalloc counts the Python heap, so SQLite's own allocations are outside every figure on this page. Both are on the list in section 6.

3. Where it breaks, named at the level of a function

The linear term is normalization, not the matcher

The reviewer's premise — that SequenceMatcher is the constraint — is wrong on this codebase's shapes, and it is the most useful thing the measurement turned up. Split at 1,600 passages, 3,287,820 characters both sides:

stagesecondsshare of the call
normalize() over both passage lists0.55087%
the list-level SequenceMatcher0.00961.5%
_alignment_confidence over the 80 modified pairs0.07011%
whole diff()0.630100%

This table only: commit 6b3f874, same machine, load average 3.05, 2.66, 2.31. split_measurement is byte for byte the same at c52b986, so the difference from the figures this table used to carry is the machine, not the code.

The cost is app/text/normalize.py::normalized_projection: a per-character Python loop that folds quotes and dashes, deletes soft hyphens, resolves hyphenated line breaks and carries a raw offset for every output character. It is O(n) and it is correct; it is simply written in Python. Anybody who reads "SequenceMatcher is quadratic", opens app/diff/engine.py and starts tuning the matcher will spend a day and find nothing.

The third row used to be an estimate and was wrong by 37%. The page said this fixture had 80 modified pairs of about 750 characters each; they average 1,027. The stage is now timed over the pairs the diff produced rather than inferred from a per-character rate and a length quoted from memory.

Read those shares to the nearest few per cent and no closer. Three runs of the same measurement, back to back at the same commit and the same load, gave normalization 87 to 88 per cent, the matcher 1.5 per cent every time, and _alignment_confidence 11 per cent every time. The stages and the whole call are timed in separate passes, so they do not have to add up: across those three runs the unattributed leftover ran from -3.8 to +1.0 milliseconds on a call of about 0.63 seconds, and it changes sign. A leftover that changes sign is timing noise, not a fourth stage. The earlier version of this table, measured on a busier machine, put normalization at 84 per cent and the leftover at +22 milliseconds; the conclusion the section rests on is the same one either way, and it gets stronger as the machine gets quieter.

The quadratic term is real, and a correctness setting causes it

app/diff/engine.py::diff passes autojunk=False. difflib's default treats any element appearing in more than one per cent of a sequence longer than 200 as junk and never matches it. On a filing, the element appearing three hundred times is the [Reserved] line, and junking it collapses the alignment inside the run. Measured on 100 unique passages plus a run of 300 identical ones with a single amendment:

settingchanges reportedpairs whose before and after read alikedistinct before-passages namedmatcher seconds
autojunk=False, shipped2010.0048
autojunk=True, difflib's default1501491500.0001

Tens of times faster and wrong — the table gives both matcher timings, and the ratio between two measurements this small is not worth a decimal place. One amendment becomes 150, of which 149 pair a passage with a passage carrying exactly the same text: a change screen full of amendments nobody made. Nothing downstream can catch it, because the citation on a fabricated change verifies — the words really are in the document. The diff is the last place the truth is known.

So the quadratic curve above is the price of that setting, and it is worth paying. tests/test_diff_scale.py fails if it stops being paid.

A third term nobody had counted

app/diff/engine.py::_alignment_confidence runs a character-level SequenceMatcher for every modified pair. It appears in neither curve because neither shape produces many long modified pairs — the repeated-run fixture produces none at all. Measured per call:

passage charactersms per callµs / char
1900.3451.81
5400.5140.95
1,5481.8861.22
4,5904.6681.02

About one microsecond per character, against 0.21 for the whole document path — so this term costs roughly five times as much per character as everything else, and it is charged only on modified pairs. A version pair where most passages are modified and long is dominated by this function rather than by either curve above. No shape in the current corpus does that, so it has never mattered; a redline of a whole tariff would.

The pipeline, which is where the real ceiling sits, and which is now measured in part

app/pipeline.py::ingest_and_diff holds both versions' passage lists in one session, builds two section maps over them, and calls record_event once per change — a flush and an audit row each. That path, not diff(), is what a production worker runs. The subsection in section 2 now measures it: on the 6,400-passage pair it costs 2.930 seconds against 2.599 for diff() alone, timed back to back in one pass, and 38.51 MB of Python heap against 14.26. What is still not measured is the shape that would make the transaction the ceiling: a pair where most passages changed. The fixture amends one passage in twenty, so the audit write per change is a small term in every figure above. A wholesale redline would make it the large one, and nothing here says how large. Section 6 keeps that on the list.

4. The design, none of which is built

Nothing in this section exists in the repository. No queue, no pool, no admission check, no partitioner. It is written down because the numbers above are what a design should be derived from, and because a design nobody wrote down gets re-invented differently by the next person. Every claim here about what the code does today is checkable; every claim about what it would do is marked as such.

The unit of work is the version pair, and it cannot be split

The unit is (company_id, proceeding_id, previous_version_id, version_id) — the argument tuple of ingest_and_diff. Not a document, and not a passage. The reason is in the code rather than in taste: app/pipeline.py::change_id derives a change's identity as CHG-{from}-{to}-{ordinal:03d}, where ordinal is the position in the whole-document diff output.

You cannot split a version pair across two workers without changing that derivation, because the second worker cannot know its ordinal offset until the first has finished. And changing the derivation is not a refactor, it is a corpus migration: rows already stored keep the old ids, a re-run mints new ones, and the row count doubles. That is the failure change_id's own docstring describes, and best-practices §27, a derived corpus migrates all at once.

The parallelism available here is therefore across pairs, and there is plenty of it: 19 dockets and 105 versions today, and a real customer has thousands of proceedings. A design that split one pair to make one filing faster would be buying latency on one screen with the identity of every change in the database.

What bounds memory: the measured constant

ingest_and_diff peaks at 3.07 to 3.14 bytes of Python heap per source character at every size measured, and that is the whole call — the document it was handed included — rather than the diff inside it. The constant is the point of measuring: a worker's footprint is predictable from the two versions' byte counts before it starts.

So an admission check would be arithmetic, not a guess. A pool of N workers under a total cap of M bytes admits a pair when 3.5 * (len(before) + len(after)) < M / N — 3.5 rather than 3.14, because the constant is rounded against the product and has to cover the spread between runs rather than the best of them. That is eleven per cent of headroom, not thirty. The thirty was arithmetic against 2.69, and 2.69 was measured on a region that did not hold the source document; when the region was widened to hold it the round-up stopped being generous and became close. It still covers the measurement, which is the only thing the guard below asserts, but a pool built on it should be sized knowing the margin is one part in nine. The same measurement taken from inside the test suite, on the same laptop, reads 3.12. A pair too large for any worker is refused by name: the docket, the version and the size that would not fit. Not started and killed. A worker killed by the operating system leaves a half-written transaction and no sentence anybody can read.

That bound covers ordinary documents. It does not cover the quadratic case, where the cost is not predictable from byte count at all — 530,000 characters of repeated rows cost more than fifty-two million characters of prose. An admission check on bytes alone would wave that through. A second check would have to count distinct normalized passages, which is one pass over the document and is the only cheap predictor of the quadratic term I know of. Neither check is implemented, and the second one is a guess I have not tested.

Three tests hold that arithmetic to the code. tests/test_diff_scale.py::test_diff_holds_close_to_one_copy_of_the_document_in_memory fails if something inside diff() starts keeping a per-character structure alive. ::test_the_page_never_admits_a_pair_bigger_than_a_worker_can_hold fails if the 3.5 above ever drops below what the pipeline measures; it exists because the first version of this page published 1.2, derived from a peak that excluded the documents themselves, and nothing caught it for a day. ::test_the_pipeline_peak_is_traced_while_it_holds_the_document fails if the document is built outside the traced region; it exists because the fix for the first fault reproduced it one region wider, and the second time nothing caught it either. Both of those errors run the same way round — they understate what the machine has to find — and this is the one arithmetic on the page where erring low gets a worker killed.

Where a queue goes, and what the JobRunner already is

Between the fetch and ingest_and_diff. Not inside diff.

app/jobs/runner.py is 1,242 lines of real scheduler and this design has to sit beside it rather than quietly duplicate it. What it already gives: JobRunner.run_once sweeps every registered Job across every company on the roster; exclusive(lock_path_for(...)) takes a file lock derived from the database URL, so two processes cannot sweep at once; JobRunner.failing counts consecutive failures per (job, company); JobLoop runs the sweep on a thread; and every sweep writes audit rows.

What it is not, and this is the distinction worth being able to state out loud: the JobRunner runs cadences, not work items. No unit of work with an identity, no payload, no retry of a particular item, no ordering between items, and no producer that can outrun it — a sweep either happens or does not, and the next one starts from the current state of the database rather than from a backlog. A queue is precisely the thing that gives a unit of work an identity, so it can be retried, deferred, refused or counted.

The producer side is already honest about itself. JOB_SOURCES is a refused job name: app/jobs/runner.py::_SOURCES_NOT_BUILT says there is no crawler and no poller and that ScheduledRun is a table of cadences with nothing behind it. app/sources/fetch.py::fetch_source does exist and is heavily tested — it retrieves one registered filing, hashes it, and stores it only if it changed — but nothing in the running product calls it; its only callers are in tests/test_fetch.py. The producer today is a function with no caller. So backpressure here is a design constraint on a component nobody has written, and saying anything more confident would be describing a system that does not exist.

How backpressure works, and what overload looks like to a user

The house answer is the only one available, and it follows from "absence is denial": when the queue is full, or a pair fails admission, the producer is refused, by name, with the reason. Never dropped. Never silently deferred to a later sweep nobody is watching.

The reason is a product fact rather than an engineering preference. A pair that was not admitted has no changes, and a change screen with no changes looks exactly like a filing that did not change. That is the worst failure available to this product: a regulatory analyst reads "no changes", closes the tab, and the amendment they are accountable for is sitting in the document. So an unadmitted pair has to be visible as an unadmitted pair — on the proceeding, in the audit trail, and on whatever screen the analyst is reading. Same rule that made JOB_SOURCES a refusal instead of a job that does nothing; same rule as best-practices §26, a fallback must announce itself.

What partitions a document, if anything ever does

Anchors, not indices — and the measurements in the next section say the obvious version of this does not answer the measured problem. It belongs there rather than here, because the interesting part of partitioning is not the mechanism, it is what it costs in correctness.

4b. The cheapest thing, which this page did not propose until it was asked for

This section exists because the design above was incomplete and an outside reading caught it. Section 4 designs a queue, a worker pool and an admission check — all of them ways to survive more work. None of them reduces the work in a single pair. Section 3 measured that normalize() is 87% of the diff call and that a version takes part in many diffs, which is a caching problem stating itself out loud, and this page quoted that number as evidence about where the cost lives without asking why it is paid more than once.

The proposal. Hash each normalised passage at ingest. At diff time compare the two sequences of hashes, take the hashes that appear exactly once on both sides as anchors, and align only the gaps between them. Equal hash means unchanged; unequal hash means nothing at all.

Measured on the Kentucky pair — 4,285 against 4,287 passages, 1,024,409 against 1,024,536 characters, the errata refiling whose two versions differ by 127 characters. Every row produced the same 144 changes as the baseline.

ApproachTimeAgainst baseline
Baseline, diff() as it ships0.404 s
Hashing at diff time, prefix and suffix trimmed0.610 s0.65x — slower
Hashes from ingest, prefix and suffix trimmed0.307 s1.32x
Hashes from ingest, anchored on unique hashes0.171 s2.36x

The losing row is the important one. Computing the hashes inside the diff costs 0.297 s, which is most of the 0.404 s it was meant to save, because hashing a passage means normalising it and normalising is the 87%. The pre-pass is only a saving if the hash is written once at ingest and read at diff time. As a diff-time optimisation it is a loss, and a plausible-sounding one.

Prefix and suffix trimming alone is weak, and the reason generalises. It removed 50.85% of the input — 1,903 identical passages at the head, 276 at the tail — and returned 1.32x. The 144 changes are scattered through the document rather than gathered at one end, so trimming the ends leaves 2,106 passages in the middle. Anchoring works between every pair of anchors instead of only at the edges, which is why it doubles the gain: 3,414 usable anchors, 286 gaps, each gap small enough that the superlinear term never gets going.

Repeated boilerplate cannot anchor, and that is the safety property rather than a limitation. 515 of the 4,285 passages on the before side — 12.0% — share a hash with another passage in the same document. A rule that matched on hash equality alone would pair the wrong occurrences. Requiring a hash to be unique on both sides excludes every one of them by construction. This is the same trap autojunk fell into by treating repeated elements as noise, which section 7 measures at 150 reported changes where 2 occurred.

What the synthetic case says, and why it is not the headline. Over generated corpora of 1,000 to 16,000 passages with 0.5% edited, the same anchoring returns 30x to 42x with identical change counts. That number is not quoted above because the generator makes almost every passage unique, which is the best case anchoring can ever have. The real filing returns 2.36x. Where the two disagree, the filing is the evidence and the generator is a ceiling.

Not built, and what is still unmeasured. None of this is in app/diff/engine.py; there is no hash column on passages and adding one is a schema change plus a derived-corpus rebuild, which is the migration rule in playbook §27. Unmeasured: the memory profile of holding two hash lists beside two passage lists; the wholesale redline where no hash matches, which pays the comparison and saves nothing and belongs in the admission check rather than here; and whether anchoring changes what _alignment_confidence reports, since aligning inside a gap gives the matcher a different neighbourhood from aligning across the whole document. The change counts matched on every case tried; the confidence values were not compared.

5. The correctness hazard in partitioning

Partitioning changes what the matcher can see: a passage in chunk three cannot align to a passage in chunk one. That much is obvious. What it costs is not, so it was measured rather than asserted. scripts/bench_diff.py::partition_measurement runs both paths over the same input and compares the change sets by identity(change_type, before offset, after offset) — never by count, because two different change sets of the same size are still two different answers.

That sentence was false when this page first carried it, for four of the columns below. The script recorded the change set for the anchor columns and only the count for the index columns, and two cells on the boilerplate row were published as agreement on that basis. They are not agreement: both paths report two changes, and the whole document names the last copy of the repeated run as the passage that went away where index partitioning names a copy 47,952 characters earlier. Every row is now compared by offsets, the two cells are marked, and tests/test_diff_scale.py::test_the_partition_measurement_calls_two_different_answers_different runs the same code over the smallest fixture with that property.

Index partitioning cuts every k passages and aligns the chunks by position. Anchor partitioning cuts only at passages whose normalized text occurs exactly once on each side, with the singletons in the same order, and takes every s-th such passage.

casewhole documentindex k=4k=8k=16k=32anchor s=5s=50s=200s=1000
shipped corpus v1→v2, 44 passages8 changes, 0.005s88888 (7 cuts, 0.008s)
shipped corpus v2→v3, 44 passages19 changes, 0.007s1919191919 (5 cuts, 0.010s)
real KY pair, 4,285 passages144 changes, 0.431s777612377260144 (682 cuts, 0.787s)144 (68 cuts, 0.796s)144 (17 cuts, 0.792s)144 (3 cuts, 0.794s)
synthetic 3,200 unique passages160 changes, 1.401s160160160160160 (608 cuts, 2.525s)160 (60 cuts, 2.470s)160 (15 cuts, 2.485s)160 (3 cuts, 2.499s)
boilerplate run of 8,000, 8,100 passages2 changes, 3.833s2
same count, different set
2
same count, different set
10102 (20 cuts, 3.999s)2 (2 cuts, 4.060s)2 (0 cuts, 4.052s)2 (0 cuts, 4.035s)
40 passages, one 8-passage section moved to the end16 changes, 0.005s2727272438 (4 cuts, 0.010s)

Bold means the change set differed from the whole-document answer — compared as offsets, on every cell in the table. Where the count agrees and the answer does not, the cell says so. Dashes mean the stride was larger than the document.

What the measurements say

  1. Naive index partitioning destroys the answer on real text. The Kentucky pair reports 144 changes whole-document and 777 at k=4 — more than five times as many, every extra one a spurious remove-and-add caused by a chunk boundary that split an alignment. It does not fail gently: even at k=32 it reports 260. Anyone who sketches partitioning on a whiteboard sketches this one.
  2. Index partitioning reproduces the answer exactly on the shipped three-version corpus, and that is a fact about the corpus, not about partitioning. Every edit in data/v1..v3 is in place, and the only length change is at the tail, so no chunk boundary ever has to move. Presenting those four "same" cells as evidence that partitioning is safe would be presenting the easiest input in the repository as a general result. A reviewer who noticed that themselves would be right to stop trusting the rest of this page.
  3. And on the boilerplate row it agrees on the count while naming a different passage. At k=4 and k=8 both paths report two changes and the two are not the same two. This is the cell that was published as agreement, and it is the most instructive one in the table: a partitioner can pass a count check on every case in a suite and still hand a reviewer a citation pointing at the wrong line of the filing. Counts are not answers.
  4. Content-anchored partitioning reproduced the whole-document answer exactly — offsets and all — on the real Kentucky pair at all four strides, on the 3,200-passage synthetic pair at all four, and on the boilerplate case. That is the encouraging result and it is real.
  5. And it failed on the moved section. Forty passages with one eight-passage block lifted to the end: the whole document reports 16 changes, anchored partitioning at stride 5 reports 38. Worse than the naive one, which reported 27. The reason is that a moved block breaks the "same order on both sides" test that anchors depend on, so the anchors thin out around exactly the edit that needed them. This is a sketch with a known failing input, and it is presented that way rather than as a solved design.

Two conclusions that matter more than the sketch

Anchored partitioning made nothing faster. The real pair: 0.787 seconds partitioned against 0.431 whole. The synthetic pair: 2.53 seconds against 1.40. About 1.8 times slower in both cases, and the reason is section 3 — finding anchors normalizes the whole document once, and then every sub-diff normalizes its slice again, so the partitioner pays the linear term twice, and the linear term is around 87% of the call. Any partitioner worth building must take pre-normalized passages, which means diff() would have to accept them. Today it cannot: it normalizes internally, which is exactly what makes it safe to call from anywhere.

Partitioning does not touch the quadratic case at all, and this is the one to lead with. The shape that goes quadratic is a run of repeated passages, and a repeated passage is by definition not unique, so it yields no anchors. On the 8,100-passage boilerplate document the anchor pass found 20 cuts at stride 5 — all of them in the 100-passage unique prefix — 2 cuts at stride 50, and none at all at strides 200 and 1,000. The cost was unchanged: 4.00 seconds against 3.83. The obvious design does not answer the measured problem. That is the single strongest argument on this page for measuring before building.

Does the deterministic guarantee survive?

Stated precisely, because the loose version of this claim is what a reviewer will probe.

diff() is a pure function and stays one under partitioning. The same two documents cut at the same points give the same changes every time, on any machine. Nothing about partitioning introduces a clock, a hash seed or a thread race.

What partitioning weakens is something else, and it is worth naming exactly: the answer stops being a function of the two documents alone and becomes a function of the two documents and the cut points. Two runs that choose different anchors — because the stride changed, or because the anchor rule was improved — give different change sets, and the change ids derived from ordinals move with them. So under partitioning the cut points become part of the input. They would have to be stored beside the changes, or the result is no longer reproducible from the corpus, and "re-run the loader and get the same database" stops being true. Today the corpus alone reproduces the answer. That is a real weakening and it should be paid for only by a measured speed-up, which section 5 has just shown does not currently exist.

What would detect a regression: running both paths over the corpus and comparing change sets by identity. That is exactly what partition_measurement does, and it is about twenty lines. The comparison itself is in the suite — one test drives bench_diff.index_partition_rows over the fixture where the counts agree and the answers do not — but the sweep across all six cases is not, because there is no partitioner to guard yet. It exists to make the claims on this page checkable, and it becomes the guard on the day one is built.

6. What is not known

7. The accuracy finding this work turned up

This is a live defect in the shipped product. It is not fixed. It is on this page because the fix has a measured cost and that cost is a scale question.
Fixed 2026-08-11, after this section was written and because it was written. The measurement below is what found the defect; _similarity now passes autojunk=False and scripts/remeasure_alignment.py recomputed every stored row, which is what made the change safe to make at all. 333 modified changes read, 25 moved, 2 cautions lifted, 0 added. The prose below is kept in the present tense it was written in rather than edited into the past, because it is the record of what the instrument reported before anything was done about it.

app/diff/engine.py::_similarity builds SequenceMatcher(None, a, b) and takes difflib's default, which is autojunk=True. Here b is a passage's characters, so any paragraph over 200 characters is past the cutoff, and every character appearing in more than one per cent of the positions is junked. In English prose that is most of the alphabet. The ratio is then computed on what survives — punctuation, digits and rare letters — and the number that reaches the change screen is a similarity between the wrong things.

Measured on real filing text from data/real/: paragraphs changed by exactly one word, 200 per band, in four length bands because the effect depends on length (a longer paragraph raises the one-per-cent bar and junks fewer characters).

paragraph lengthnscored ≤ 0.50 by the shipped codeworst shipped scoretrue similarity of that pairmean shippedmean truecost of autojunk=False
200–400 chars20020.4440.9640.9570.9800.0001s → 0.0009s
400–80020000.5150.9920.9700.9900.0002s → 0.0028s
800–2,00020010.5000.9940.9840.9950.0006s → 0.0168s
2,000–20,00020080.4800.9920.9740.9960.0262s → 1.0911s

The cost column times the longest pair in each band: 389, 754, 1,942 and 18,590 characters. Paragraphs over 20,000 characters are not in this sample, because the honest setting is quadratic and one long exhibit costs more than a thousand paragraphs — so this table says nothing about very long passages.

LOW_ALIGNMENT in app/web/views/changes.py is 0.50. 11 of the 800 real paragraphs sampled — one in 73 — cross that line after a one-word edit, and the worst of them scores 0.444 against a true similarity of 0.964. Each of those would carry a low-alignment caution on the change screen that the text does not support. No score was exactly zero in this sample.

The direction is safe, which is why this is a finding and not an incident. Junking only removes matches, so the defect can only push a score down: it over-flags and it can never under-flag. A change whose text really did move a long way cannot be rescued into a high score. So it fails toward review, as ADR-003 requires. What it costs is trust in the caution — a reviewer who keeps seeing the low-alignment flag on changes that plainly moved one word learns to ignore the flag, and then the flag is worth nothing on the day it is right.

Why no existing test can see it: every fixture in tests/test_diff.py is under 200 characters, so difflib's cutoff never fires and the ratio is exact.

Why it is not fixed here. The fix is autojunk=False in _similarity too, and it costs between 9x and 42x on the character path depending on passage length. More importantly it rewrites alignment_confidence on every stored change row, which makes it a derived-corpus migration (best-practices §27) rather than a one-line edit: until the corpus is reloaded, the screen, the stored rows and this page would each say something different. It needs its own decision and its own reload.

Where it is written down: here and in the findings table, and the ADR is still missing. This page once closed by saying the defect was recorded as open in docs/.ai/findings.html. It was not — grep that file for autojunk and it returned nothing, and the change that wrote this page had not touched it. A page pointing at a tracker that does not carry the defect is worse than one admitting the defect is untracked, because the first stops anybody looking. It is finding 17 in that table now, marked open, with no guard and the reason there is none. What is still missing is the decision itself: not fixing a live defect because the fix is a corpus migration is a choice, and a choice with alternatives and a cost belongs in docs/.ai/decisions.html rather than in the resolution column of a table.