feat(explore): U4+U13 del 3 - multi-base er en PARTISJON, ikke en videre signatur (ORDRE 20260825T080753Z) [skip-docs]

Ordrens premiss («endrer run_project sin signatur») er FALSIFISERT foer bygging,
og ordren ba selv om nettopp den sjekken. run_project kan ikke ta mer enn en
bundle_dir: paa bundle-stien avleder den FIRE enkeltverdier fra DEN basen -
prosjektet (_project_from_bundle fail-faster naar basens egen project_id ikke er
den forespurte), validatorens stage-0-baseline, agentenes lesekontekst og
ExpeL-noekkelen - og returnerer ETT stemplet RunResult. En andre katalog paa den
signaturen ville tvunget et stille velg-en for alle fire. Planens egen setning
sier det samme lest naert: «pipelinen kjoeres per bundle som i dag
(run_portfolio-formen)» = N kall, ikke ett kall med N.

Levert form, tre soemmer:
- mandate.Approach.bundle_id (default "" - hvert mandat skrevet foer i dag er
  fortsatt gyldig og dispatchbart uendret)
- mandate.route_by_bundle - ren partisjon, fail-fast paa et mandat som ikke kan
  utfoeres som skrevet (load_mandate-regelen). En base som ingen approach navngir
  kjoeres ikke; med NOEYAKTIG en base absorberer den alt uten navn, som ikke er en
  gjetning men det eneste mulige svaret.
- run.run_mandate_across_bundles - dispatchen. INGEN project_id-parameter:
  hver base sitt prosjekt leses fra DEN basens egen IR-projeksjon, altsaa den
  verdien _project_from_bundle allerede fail-faster mot. En delt VerdictStore
  traades paa tvers (kryss-base-laering, run_portfolio-formen), og med
  portfolio_meter gjelder de to S3.4-tennene som HAR mening her: oppstartsnekt
  (BudgetRefused) og aldri-startet + budget_stop + not_evaluated-rader.

INGEN eksisterende kaller endrer signatur - CLI, hosting og simulation sender
fortsatt en base hver, og kan fortsatt gjoere det.

explore(): hver myntet approach baerer bundle_id; markoeren kan navngi basen;
en umerket markoer med flere baser NEKTES (HypothesisParseError), med en base
resolveres den. Froe-approaches VALIDERES men skrives ALDRI om (§ C.6 doer 1 er
en bevaringsregel) - og valideringen skjer FOER foerste modellkall, samme
oekt-57-hoist-grunn: ved unntaket alene ser en nekt etter forbruket identisk ut.
explore()-docstringens gamle aerlighets-grense («venter paa at run_project tar
mer enn en bundle_dir») er RETTET - den ville vaert usann fra i dag.

Load-bearing MAALT, 12 mutasjoner alle roede mot HELE suiten, groenn kontroll
1020/5 (fra 999/5 + 21 nye tester); golden demo-transcript.stdout BYTE-UENDRET
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f): detach myntet bundle_id (2) ·
stille gjennomfall ved >1 base (1) · ukjent id resolvert etter rekkefoelge (1) ·
froe-sjekk etter forbruket (2) · ruteren gjetter foerste base (1) · uroutbar
approach droppet (1) · dispatchen kollapser til en base (5) · project_id fra
foerste base (2) · detach aldri-startet-tannen (1) · unreached urapportert (1) ·
fersk store per base (1) · detach oppstartsnekten (2).

EN MUTASJON FALSIFISERTE TESTEN FOERST (repoets vakuoes-gate-klasse, aattende
gang): store-testen sammenlignet med ==, og VerdictStore er en pydantic-modell
med VERDI-likhet - tre ulike tomme stores er alle like, saa «fersk store per
base» lot hele suiten staa groenn. Delt INSTANS er paastanden, saa testen
asserterer naa paa `is`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3YHobQC3WzYVoxSgZus4d
This commit is contained in:
Kjell Tore Guttormsen 2026-08-25 13:35:39 +02:00
commit 18af86e422
6 changed files with 1051 additions and 17 deletions

View file

@ -703,9 +703,46 @@ class HypothesisParseError(ExplorationError):
"""
def _parse_hypotheses(texts: Sequence[str]) -> list[tuple[str, str]]:
"""Every marked ``(label, rationale)`` pair the hypothesiser committed to, in turn order."""
found: list[tuple[str, str]] = []
def _resolve_hypothesis_bundle(raw: str, bundle_ids: Sequence[str]) -> str:
"""Which knowledge base a marked hypothesis belongs to (§ C.7), fail-closed.
Three rules, and the middle one is the whole reason the field can default at all:
* a stated id must be one that was CONFIGURED refused by name, never resolved by position,
exactly as ``_resolve_bundle`` refuses an unknown base for the read tools. A hypothesis that
names a base must not be treated more leniently than a read of one.
* a silent marker with at most one base configured resolves to that base (or to ``""`` with
none). With one configured base there is no other value the field could take, so this is the
only answer rather than a guess.
* a silent marker with SEVERAL configured refuses. A marked line is a claim the loop committed
to; one that cannot be routed is a claim it could not finish making, and the marker is
precisely what makes that affordable to refuse an unmarked turn is not a hypothesis, so
there is no silence being turned into an error.
"""
if raw:
if raw not in bundle_ids:
known = ", ".join(bundle_ids) or "(none configured)"
raise ExplorationError(
f"hypothesis names knowledge base {raw!r}, which is not configured for this "
f"exploration; configured: {known}"
)
return raw
if len(bundle_ids) <= 1:
return bundle_ids[0] if bundle_ids else ""
raise HypothesisParseError(
f"a marked hypothesis must name its knowledge base when several are configured "
f'({", ".join(bundle_ids)}): add "bundle_id" to the marker payload. Routing it here '
"would evaluate a direction against a project nobody asked about"
)
def _parse_hypotheses(
texts: Sequence[str], bundle_ids: Sequence[str]
) -> list[tuple[str, str, str]]:
"""Every marked ``(label, rationale, bundle_id)`` triple the hypothesiser committed to, in turn
order. The base is resolved HERE rather than at minting time so an unroutable claim is refused
while the line that made it is still in hand for the error message."""
found: list[tuple[str, str, str]] = []
for text in texts:
for line in text.splitlines():
stripped = line.strip()
@ -724,12 +761,48 @@ def _parse_hypotheses(texts: Sequence[str]) -> list[tuple[str, str]]:
"a marked hypothesis needs a non-empty 'label' and 'rationale'; got: "
f"{stripped}"
)
found.append((str(data["label"]), str(data["rationale"])))
found.append(
(
str(data["label"]),
str(data["rationale"]),
_resolve_hypothesis_bundle(str(data.get("bundle_id") or ""), bundle_ids),
)
)
return found
def refuse_unroutable_seeds(seeds: Sequence[Approach], bundle_ids: Sequence[str]) -> None:
"""Refuse an expert's seed that could not be dispatched — BEFORE the first model call.
Validation, ALDRI reparasjon, and here the second half is the load-bearing one: a seed is the
expert's own words and § C.6 door 1 is a PRESERVATION rule, so filling in a missing
``bundle_id`` on their behalf would put their name on a routing decision they did not make.
The single-base default belongs to ``route_by_bundle``, at consumption, where it is unambiguous
by construction never to a rewrite of the input.
Called before the loop starts rather than after it returns, and that placement is the økt-57
outbox-hoist precedent: at the exception alone, a refusal after the exploration has spent its
whole budget looks identical to one before it spent anything. The mandate is undispatchable
either way; what is at stake is whether the expert pays to find that out.
"""
for seed in seeds:
if seed.bundle_id:
if seed.bundle_id not in bundle_ids:
known = ", ".join(bundle_ids) or "(none configured)"
raise ExplorationError(
f"seed approach {seed.id!r} names knowledge base {seed.bundle_id!r}, which is "
f"not configured for this exploration; configured: {known}"
)
elif len(bundle_ids) > 1:
raise ExplorationError(
f"seed approach {seed.id!r} names no knowledge base and {len(bundle_ids)} are "
f"configured ({', '.join(bundle_ids)}); set Approach.bundle_id so the mandate can "
"be dispatched to the base the hypothesis is actually about"
)
def _mint_approaches(
seeds: Sequence[Approach], discovered: Sequence[tuple[str, str]]
seeds: Sequence[Approach], discovered: Sequence[tuple[str, str, str]]
) -> tuple[Approach, ...]:
"""Seeds FIRST, untouched, then one approach per discovered direction.
@ -742,7 +815,7 @@ def _mint_approaches(
taken = {approach.id for approach in seeds}
minted: list[Approach] = list(seeds)
counter = 0
for label, rationale in discovered:
for label, rationale, bundle_id in discovered:
counter += 1
candidate = f"hypothesis-{counter}"
while candidate in taken or candidate == OWN_PROPOSAL_ID:
@ -752,7 +825,12 @@ def _mint_approaches(
# description is the hypothesiser's own words, VERBATIM: ``generate._build_messages`` feeds
# an Approach.description to the proposer unchanged, and the reason a direction is worth
# trying is exactly the half a model cannot re-derive from the cost table.
minted.append(Approach(id=candidate, label=label, description=rationale))
#
# ``bundle_id`` is stamped on a MINTED approach because there is nothing here to preserve —
# the opposite call from the seeds above, which pass through untouched (§ C.6 door 1).
minted.append(
Approach(id=candidate, label=label, description=rationale, bundle_id=bundle_id)
)
return tuple(minted)
@ -792,13 +870,19 @@ async def explore(
returned mandate whatever the loop found, including when the loop found nothing and including
when it stopped early.
Honesty limits, stated rather than implied. (1) A run is bound to ONE mandate; multi-base
dispatch (``Approach.bundle_id``, § C.7) waits for ``run_project`` to accept more than one
``bundle_dir``, and shipping the field before its consumer would be a shape guessed instead of
measured. (2) The ``quick_validate`` verdicts the hypothesiser saw are not in
**Multi-base (§ C.7).** Every approach in the returned mandate carries ``bundle_id``, and
``mandate.route_by_bundle`` partitions the commission by it so ``run.run_mandate_across_bundles``
can run the pipeline once per base. ``run_project`` itself still takes ONE ``bundle_dir``, and
that is the measured shape rather than a step not yet taken: it derives the project, the
validator's cost baseline, the read context and the ExpeL query key from THE bundle and returns
one stamped ``RunResult``, so a second directory on that signature would force a silent
pick-one for all four.
Honesty limits, stated rather than implied. (1) The ``quick_validate`` verdicts the
hypothesiser saw are not in
``ExplorationResult``: they are level-1 advisory, and their home is the
``{run_id}-exploration.json`` artefact the CLI wiring writes pass an ``ExplorationTrace`` to
collect them. (3) The exploration roles resolve through ``resolve_model``'s ``default``
collect them. (2) The exploration roles resolve through ``resolve_model``'s ``default``
fallback unless an operator maps them explicitly.
``trace`` is the caller's accumulator and is the ONLY way to see what a run that RAISED
@ -816,6 +900,12 @@ async def explore(
"requested and the reviewer would never be called (refused, never silently ignored)"
)
# The base ids, resolved ONCE and before anything is built: they are what a seed and a marked
# hypothesis are both routed against, and ``_bundle_index`` is the one place that decides them
# (a duplicate basename refuses here rather than letting the manager read A believing it read B).
bundle_ids = tuple(_bundle_index(bundle_dirs))
refuse_unroutable_seeds(seed_approaches, bundle_ids)
if meter is None:
meter = TokenMeter(Budget(max_tokens=contract.max_tokens, max_rounds=contract.max_rounds))
if client_factory is None:
@ -912,7 +1002,9 @@ async def explore(
if stop is None:
stop = _classify_stop(ledger_log, replans=replans, contract=contract)
discovered = _parse_hypotheses(hypothesis_texts) if stop != "unknown_speaker" else []
discovered = (
_parse_hypotheses(hypothesis_texts, bundle_ids) if stop != "unknown_speaker" else []
)
return ExplorationResult(
mandate=Mandate(
objective=prompt,