Skip to content

PROPOSAL-3: storage that streams, shape measured, concurrency, bounded reasoning - #26

Merged
R204570 merged 7 commits into
mainfrom
proposal-3
Aug 30, 2026
Merged

PROPOSAL-3: storage that streams, shape measured, concurrency, bounded reasoning#26
R204570 merged 7 commits into
mainfrom
proposal-3

Conversation

@R204570

@R204570 R204570 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Why

go.dev was harvested for sixteen minutes, hit one 1.19 MB page, and stored none of the ~1,200 pages that had already extracted cleanly. This branch implements all four phases of PROPOSAL-3.md to make that impossible.

The root cause was not what we assumed

We expected "large pages need splitting." It wasn't that.

page.search is a generated column, so to_tsvector runs during the INSERT. Postgres's 1 MB tsvector ceiling was therefore not an indexing limit but a storage limit - the row could not exist at all. One left(content, 300_000) removes it (_upgrade_v4). The page is stored and served whole; the section table keeps the tail searchable so bounding the index doesn't trade a visible failure for an invisible one.

Phases

Phase Change
1 - streaming storage store().writer() in both backends, save() delegates to it. Per page, not the batches of 50 the proposal named - a batch of 50 contradicts Invariant 16 in the same document
2 - shape + bounded index Probe to classify_shape / magnitude; section table; federation completeness becomes the headline
3 - concurrency Prefetch window, per-host pacing, HOST_CONCURRENCY. 1.30s to 0.38s at 4 workers, same pages in the same order
4 - bounded reasoning Budget(calls=12), 4 decision points, cached per template/host, off by default

The wiring debt

PROPOSAL-II shipped features that were built, tested, and never called. All had passing tests - passing tests proved behaviour, not reachability.

  • classify_shape - zero callers, so every corpus was a tree
  • Corpus.magnitude - always 0, so every escalation read "size unknown"
  • Federation.complete - tested, never read; the headline came from the entry corpus
  • the entry corpus could be reported "not requested" despite being stored

Each is now wired with a grep-the-source assertion. Two of those assertions caught real defects within minutes of being written.

Found while building

  • A corpus was compared against a median it was itself in - so the one giant document could never be six times it, and the page branch would have stayed unreachable in exactly the case it exists for.
  • _upgrade_v4's guard looked for left(, but Postgres renders the reserved word as "left"( - it would have rebuilt a table and a GIN index on every process start, silently.
  • _federate rendered its own coverage note that had drifted into claiming coverage described "only the corpus that was crawled", long after selection began harvesting the others.
  • api is not an intent; unknown intents fall back to permissive, so the first version of a test passed while proving nothing.
  • The section table went a whole phase populated with nothing reading it - the exact defect this branch exists to fix. search() now unions both indexes.

Not done

  • P1 - the spec wants an interrupted harvest to leave 60% readable. Only "previous version intact" is true. This is a contradiction inside the spec: blue/green invisibility is what Invariant 17 requires. Needs a human decision.
  • P2 - job progress does not survive a process kill (harvest_jobs._JOBS is in-memory). The pages survive; the report of them does not.
  • R1 is still open - the identity gate confirms the name, not the project. Reasoning can veto a wrong admission but is off by default, so the default path is unchanged. Still the top defect.

The spec also asked that ISSUES.md gain no entries. That is broken deliberately - a criterion met by not recording a known gap is met dishonestly. It closed 12 and opened 2.

Testing

643 passed, 22 skipped with Postgres exercised against DocsForgeTest. Phase 4's criterion - "with reasoning off, behaves exactly as Phase 3" - holds checkably: all 611 Phase-3 tests pass unchanged, none mentions reasoning.

CI note: DOCSFORGE_TEST_DB must be set or 22 Postgres tests skip silently and the run looks greener than it is.

R204570 and others added 7 commits August 29, 2026 22:32
PROPOSAL-II said what the system should do. This says when it should pay.

Everything is currently written at the end, so one failure costs everything:
go.dev crawled 16 minutes, met one 1.19 MB page, and stored none of the ~1200
that had extracted cleanly. The same deferral shape produced all 32 open
issues.

Four changes: streaming blue/green storage so a page is durable before the
next is fetched; sections as the storage unit so the tsvector ceiling becomes
unreachable; a bounded reasoning budget at the four decision points that
decide correctness, cached per cluster and off by default; and a bounded
worker pool with a per-host politeness cap.

Adds four invariants, and amends II's "no LLM in the per-page loop" in the
open rather than quietly. Makes the dev cycle part of the design: an issue
found in a cycle is fixed in that cycle, never filed and carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
go.dev crawled for sixteen minutes, met one 1.19 MB page, and stored none of
the ~1,200 that had extracted cleanly. Four defects stacked: the whole harvest
went in one COPY in one transaction, so one refused row discarded every good
one; the driver's "string is too long for tsvector" reached the caller, which
concluded the docs were too big for the database and offered to harvest a
subset instead; nothing was durable until the end; and the entire corpus was
resident in memory to get there.

Storage now streams. Both stores expose writer(); save() delegates to it, so
there is one write path rather than a streaming one and a batch one that rots.
Pages commit individually, so a refused page costs that page — named in
entry["rejected"], surfaced as stats["unextractable"] — and never the harvest.
An in-flight version is invisible to readers until it settles, and settling
promotes it over the previous one, so an abandoned harvest leaves what was
already stored untouched.

Per page rather than the batches of 50 the proposal named: a batch of 50 means
the first page is not durable until the fiftieth is fetched, which contradicts
Invariant 16 in the same document. Fetching dominates; the extra round trips
do not show up on a 1,200-page harvest.

Dead symbols (W6): _federated_note, passages.passages and Corpus.key deleted;
Federation.single and Federation.note now wired into _federate, which had been
rendering a second coverage note of its own. That one had drifted into claiming
the coverage described "only the corpus that was crawled" long after selection
began harvesting the others — exactly the drift W6 predicted, found by deleting
it. Selection.as_dict stays: it is not a stray, it is the fix for W5.

Deleting Corpus.key also removed the only expression of "an unversioned corpus
files under undated" — which lived only in dead code. The live path labelled
such corpora with today's date, a claim about when the content is from that an
undated corpus cannot make. Now forge_tools.corpus_label().

Closes S1, S2, S5, S6, W6. 576 passed, 22 skipped, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
Two halves of the same failure, fixed together because they share one cause.

Shape and magnitude were built, tested and never called (W2, W3). With every
corpus classified `tree`, the `page` branch of _harvest_corpus was unreachable,
so a specification published as one enormous document was crawled as a site,
found one page, and — being one 1.19 MB page — stored none of it. _measure_corpora
now probes each admitted corpus with a single GET: enough for chars, in-page
anchors, in-scope links and whether the site publishes its own page list.

Two defects surfaced the moment that had a caller. has_manifest had been
accepted and never read for the whole of PROPOSAL-II; it now rules out `page`,
because a site publishing a list of its own pages is not one document however
long its landing page is. And a corpus was measured against a median it was
itself part of — with two or three corpora the one enormous document dominates
that median outright and so can never be six times it, leaving the branch
unreachable in precisely the case it exists for. The median is now over peers.
The same mistake _neighbourhood made counting a URL as its own neighbour.

The storage half no longer depends on getting any of that right. page.search is
a GENERATED column, so an unbounded index expression made Postgres's 1 MB
tsvector ceiling a storage limit rather than an indexing one: the page could not
be inserted at all. It is now generated over left(content, 300_000). The page is
stored whole and served whole — the bound is on the index, not the page — and
anything past it is indexed section by section through the new `section` table,
so bounding the index does not quietly drop the tail of a specification out of
search. An ordinary page is not split; splitting every page would double the
store to buy nothing.

_upgrade_v4's guard looked for `left(` in the column definition. Postgres
renders the reserved word quoted as `"left"(`, so it never matched and every
migrate() dropped and rebuilt a generated column — a table rewrite and a GIN
index rebuild on every process start, silently. It now matches on the bound.
Caught by the test asserting the column definition, which failed the same way;
there is now a test comparing the column's attnum across two migrations, because
no behavioural test would ever have noticed.

Closes S1 (properly, not just survivably), S4, S7, W2, W3. Narrows S3.
593 passed, 22 skipped, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
Two wiring defects with one shape: PROPOSAL-II built the honest answer, tested
it, and then showed the reader a different one.

W1. Federation.complete implements Invariant 9 and nothing called it, so the
headline came from the entry corpus's stats. A harvest that got all of one
corpus and half of another announced itself complete, with the shortfall
visible only to whoever scrolled to the note at the bottom. The fix is an
ordering one: _federate is what discovers, admits and harvests the other
corpora, so until it has run there is no federation-level completeness to
report. It now runs before the headline is written, records the roll-up in
stats["federation"], and the headline uses it whenever more than one corpus is
in play.

W4. The entry corpus — the URL the caller handed in, already crawled and
already stored — could be reported "not requested", because classify_kind
returns "" for a docs root with no kind token in its path (most of them) and an
unclassified corpus matches no kind-specific intent. Corpus.entry now marks it
and _mark never deselects it. Invariant 5 still applies to everything else: a
peer that genuinely was not requested still says so, with its magnitude.
Selection.selected is now derived from the marks rather than set beside them,
so the coverage note and the harvest loop cannot work from different lists.

W7. There are now wiring assertions for each of these, which is the only reason
W1-W4 and W6 can be called closed with any confidence that they stay closed.
Two earned their place immediately: the one-renderer assertion caught _federate
still building its own coverage note, and the bounded-index assertion caught a
migration guard that would have rebuilt a table on every startup.

A test written for this found that `api` is not an intent at all — unknown
intents fall back to a permissive spec that wants everything, so the first
version of the W4 test passed while proving nothing. It now uses
`resolve-import`, which genuinely excludes `guide`.

Closes W1, W4, W7. Phase 2 complete.
600 passed, 22 skipped, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
go.dev spent sixteen minutes on roughly 1,200 pages — most of a second per page
spent waiting on the network with one connection open. Fetching is almost all
of a crawl's wall-clock time and almost none of its CPU, so it is the only
place a real speedup lives.

A bounded prefetch window now runs ahead of the crawl: pages are dispatched in
queue order, results consumed in dispatch order, and everything else — the
frontier, the ledger, the plan, link discovery, the sink — stays on one thread.
That is why there is no lock on Plan.revise and no thread-safe frontier in this
commit despite the phase naming both: nothing is shared, so there is nothing to
lock, and ordering is preserved by construction rather than defended after the
fact. A concurrent crawl returns the same pages in the same order, which is
half the acceptance criterion and the half that is easy to lose.

Measured: 24 pages at 50ms latency, 1.30s at one worker and 0.38s at four.

Politeness moved from sleeping between completed pages to spacing request
starts per host, which is where the speedup comes from. At a 0.4s delay and
0.8s per page, sleeping between completions costs 1.2s per page and overlaps
nothing; spacing starts costs the host the same 0.4s with several requests in
flight. _Pace reserves a slot under its lock and sleeps it off outside, so a
worker waiting its turn does not hold the others up, and one host waiting never
makes another host wait.

Rendering stays sequential whatever the caller asks for. Playwright's sync API
is bound to the thread that created the browser and a Fetcher keeps exactly
one, so --js falls back to a single worker. A correctness constraint, not a
tuning choice.

Two things the window broke that needed fixing with it:

Truncation accounting. Pages prefetched but never processed have been taken off
the queue, so counting only the queue understated a shortfall by up to `workers`
pages — and would report `whole` for a crawl that stopped with pages in hand.
Undercounting a shortfall is the one direction this must never round.

Invariant 16 said "a page is durable before the next is fetched", which
concurrency makes literally false. Amended in PROPOSAL-3.md to "before the next
is stored", with what it protects stated at its real strength: an interruption
can lose at most `workers` pages of fetching and never anything already stored.
An invariant nobody can rely on literally is worse than one stated honestly.

611 passed, 22 skipped, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
PROPOSAL-II said "No LLM in the per-page loop", and that rule was right: a
model call per page on a 1,200-page site is the huge bill this project exists
not to send. This is the amended version — no LLM in the loop, a bounded
number of calls at the four moments that decide whether a harvest is correct,
cached so a decision is made once per template or per host rather than once
per page.

  1. A template none of the nine selectors recognise. Density refuses, and
     refusing is usually right — the link ratio is what stops a sidebar being
     stored as documentation. On an API reference it is wrong, and the page is
     lost. One cached call per template tells those apart.
  2. Kind confidence below threshold. Moved from `selection`, which has only a
     URL to go on, into `_measure_corpora`, where the probe has already paid
     for the page. Escalation to a human stays the fallback, and the confidence
     granted is capped at the escalation threshold rather than above it: a read
     answer beats a URL guess and is still weaker than a path that says so, and
     claiming otherwise would silence the escalation that exists for this doubt.
  3. A corpus proposed on a new host — ISSUES.md R1, the top open defect. The
     gate confirms a page is about something with this name, not that it is
     this project, and the evidence it counts is identical in both cases. The
     consultation may only VETO: a host the algorithmic gate refused is never
     re-admitted by asking, so reasoning makes the gate stricter and never
     looser, and turning it on cannot make R1 worse.
  4. A page answering 200 while rendering an error, which no status code shows.
     Gated on a cheap short-and-error-shaped check first, so a site full of
     soft-404s still costs at most one call per template.

Invariant 18 is what makes spending anything here safe, and each clause is a
test rather than an intention. Bounded: 12 calls per harvest, one budget for
the entry corpus and its federation together, exhausted means fall back and
never stall. Cached: 500 pages of one template cost one call. Optional: off
unless DOCSFORGE_REASONING is on AND a provider is configured, two independent
switches because neither implies the other. Recorded: every consultation, its
question, its answer and whether it was cached or fell back, in
stats["reasoning"] beside the coverage note.

The model proposes and the code disposes throughout — every answer is
validated before it is trusted, a rejected answer is not cached, and a provider
that raises costs a fallback rather than a harvest.

The acceptance criterion was "a harvest with reasoning disabled behaves exactly
as Phase 3 did". It is met in a checkable rather than an intended way: all 611
tests from Phase 3 pass unchanged, none of them mentions reasoning, and every
one runs with it off.

638 passed, 22 skipped, Postgres exercised. All four phases built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
Checking the four phases against PROPOSAL-3 §6's own acceptance criteria found
four shortfalls. Two are fixed here; two are written down.

The section table had no read path. It was created, migrated and populated for
a whole phase while nothing queried it — which is exactly the defect the entire
W-series of ISSUES.md is about, committed while closing it. An index nobody
reads is the same as no index: bounding page.search would have traded a page
that could not be stored for a page stored whole and findable only by its
opening. search() now unions the page index with the section index, ranks by
the better of the two, and returns one result per page.

Per-host concurrency was promised and not implemented. Pacing bounds request
frequency, which is not the same thing: four requests spaced 0.4s apart are
still four open sockets if each takes two seconds, and it is open sockets
rather than frequency that a small documentation host notices. _Pace now holds
a per-host semaphore and records a high-water mark, because §6 asks for this
asserted and a cap nobody measures is a comment.

Two criteria are not met, and are now ISSUES.md P1 and P2.

P1 — "an interrupted harvest leaves 60% of its pages readable and the previous
version intact". Only the second half is true, and this is a contradiction
inside §6 rather than a gap in the code: blue/green makes an in-flight version
invisible to every reader, which is what Invariant 17 and the refusal to serve
undisclosed subsets both require. Whether a partial harvest should be readable
at all is a decision, not a bug.

P2 — "progress that survives killing the process". harvest_jobs._JOBS is an
in-process dict, so it does not. The pages survive, which is the half that
matters; the report of them does not.

§6 also asked that ISSUES.md gain no entries across the four phases. Writing
these down breaks that, deliberately: a criterion met by not recording a known
gap is met dishonestly, and the file shrank by nine entries regardless.

643 passed, 22 skipped, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJCjMch9zNymRVrGhyG3y
@R204570 R204570 self-assigned this Aug 30, 2026
@R204570
R204570 merged commit 87b5ddd into main Aug 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant