feat(s7b): IR-projeksjonen er valgfri paa alle tre kallsteder, og fraveret sies

DEL A + DEL C av ordre 20260903T204605Z-215167684-from-.claude.

Soem 1: okf.load_optional_ir_projection ved siden av den fail-faste, moensteret
fra load_cost_baseline/load_optional_cost_baseline. Et PAR, ikke et required=-flagg:
PM-tillegg 5 maalte hva en uoevet parameter koster (elleve evidence_for-kallsteder
brukte defaulten til den andre grenen raatnet), og et flagg ville dessuten gjort
usanne de fem docstringene som siterer load_ir_projection som DEN fail-faste
presedensen. Toleransen stopper ved fravaer: en malformed projeksjon reiser fortsatt.

De tre kallstedene fikk HVER SIN stilling:
  * _project_from_bundle - fravaer hopper over en fail-fast som ikke har noe aa
    sjekke mot; en projeksjon som FINNES og navngir et annet prosjekt nekter
    fortsatt. Divergens-vakten er kontrakten multi-base-dispatchen hviler paa.
  * run_mandate_across_bundles - FILA FOERST, basens ERKLAERTE bundle_id som
    fallback (S7a-3). Presedensen baerer i begge retninger: erklaering-foerst ville
    re-adressert hver eksisterende base der project_id != bundle_id.
  * bundle_candidate_features - optional_bundle_candidate_features, og de TO
    konsumentene svarer ULIKT paa fravaeret. Steg-1-folden HOPPER OVER og sier
    hvor mange tidligere dommer som dermed aldri naadde hypotese-prompten;
    seed_store_from_bundle NEKTER ved navn (VerdictKeyUnavailable), fordi aa mynte
    en noekkel for en dom som erklaerer ingen er nettopp defekten S3.2 lukker.

Synligheten: RunResult.unkeyed_verdicts (ANTALL, ikke flagg - koe-(y)-regelen) +
run.unkeyed_verdicts_notice som ENESTE renderer, None ved null (omisjon, aldri tom
rad). Baereren er MAALT: dry-run-kuttet returnerer OVER folden, saa et felt paa
DryRunReport kunne bare rapportert null - ulikt cost_baseline_anchored og
skipped_links, begge opploest over kuttet. Ikke paa ProvenanceStamp: stempelet
beskriver gaten som doemte EN kandidat.

Prosjektnavnet var et ikke-spoersmaal, og det er maalt: SavingsProposal har intet
navnefelt, saa projeksjonen har aldri vaert en navnekilde. Project.name kommer
fortsatt fra type: project-konseptets title med id-en som siste utvei.

DEL C: --mandate lagt i report_forbidden. Den var ELDRE enn partisjonen og hadde
aldri faatt en rad, saa --report --ledger X --mandate Y droppet kommisjonen i
STILLHET - F4-klassen. Testarmen kjoerer mot en argv report-modus ellers ville
AKSEPTERT, med en kontroll som beviser rc 0 uten flagget.

Kontroll: 1290 passed / 5 skipped (fra 1275/5 - supersett, 0 fjernet).
Golden demo-transcript.stdout BYTE-UENDRET, shasum -a 1 (INNHOLD, ikke git-blob)
= ea8c534773acdbe41ae68f2c55724d69aaf8be4f. ruff + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 08:09:03 +02:00
commit b75387ca25
4 changed files with 593 additions and 14 deletions

View file

@ -1520,3 +1520,27 @@ def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str,
raise FileNotFoundError(f"IR projection not found in bundle: {name!r}")
data: dict[str, Any] = json.loads(resolved.read_text(encoding="utf-8"))
return data
def load_optional_ir_projection(
bundle_dir: str, name: str = _IR_PROJECTION
) -> dict[str, Any] | None:
"""``load_ir_projection`` where a MISSING file is legitimate: returns ``None`` instead of
raising (S7b søm 1). This is the run path's loader — an INGESTED corpus carries no hand-written
IR projection, and requiring one meant such a base could be navigated, catalogued and judged
through the deterministic mandate door and still not run the eight-step loop at all.
A PAIR beside the fail-fast loader rather than a ``required=`` flag on it, mirroring
``load_cost_baseline`` / ``load_optional_cost_baseline`` exactly. A flag would leave one branch
unexercised wherever callers took the default, which is the rot PM-tillegg 5 measured on
``evidence_for``; and it would falsify the several docstrings across the package that cite
``load_ir_projection`` as *the* fail-fast precedent.
The tolerance stops at absence: a projection that EXISTS but is malformed still raises, and a
name escaping the bundle still raises. Reading a corrupt projection as "no projection" would
hand back an unkeyed run under the appearance of a keyed one the same reasoning as
``load_optional_cost_baseline`` and ``budget.read_spend``."""
try:
return load_ir_projection(bundle_dir, name)
except FileNotFoundError:
return None

View file

@ -121,7 +121,7 @@ from portfolio_optimiser.verdicts import (
ProposalFeatures,
Verdict,
VerdictStore,
bundle_candidate_features,
optional_bundle_candidate_features,
capture_verdict,
load_verdicts_from_dir,
similarity,
@ -183,6 +183,22 @@ class RunResult:
#: describes the gate that judged ONE candidate. EMPTY on the road path (no bundle is navigated)
#: and on any bundle that was read whole; it defaults for the same reason ``coverage`` does.
skipped_links: tuple[okf.SkippedLink, ...] = ()
#: How many prior expert verdicts were in the store but could NOT be folded into the hypothesis
#: prompt, because the knowledge base declares no IR projection to key retrieval against (S7b
#: søm 1). 0 is the honest POSITIVE statement — every verdict the store held was keyable, which
#: covers both "the fold ran" and "there was nothing to fold" — so it defaults, exactly as
#: ``skipped_links``' empty tuple does.
#:
#: A COUNT, not a flag: "the fold did not happen" and "two judgements never reached the model"
#: are different operative facts, which is ``BudgetExceeded``'s kø-(y) rule one level down.
#:
#: Carried HERE and on neither of the other two carriers, and that placement is MEASURED.
#: ``ProvenanceStamp`` describes the gate that judged ONE candidate, whereas this is settled
#: once per run before any candidate exists (``skipped_links``' own reason). ``DryRunReport``
#: cannot carry it at all: the dry-run cut returns ABOVE the fold, so a field there could only
#: ever report zero — unlike ``cost_baseline_anchored`` and ``skipped_links``, both resolved
#: above that cut.
unkeyed_verdicts: int = 0
@property
def verdict_key(self) -> str:
@ -523,11 +539,22 @@ def _project_from_bundle(
"""Derive a minimal ``Project`` from an OKF bundle (so a bundle the loop runs need NOT be a
road reference-domain project). Only ``id`` + ``name`` reach the generation prompt
(``generate._build_messages``), so ``cost_items`` is empty and ``verdict_input`` is left unset
here (the Layer-2 decision flows via ``run_project``'s own ``verdict_input`` argument). Fail-fast: the bundle's IR
``project_id`` must match the requested id. ``bundle`` reuses an already-navigated bundle to
avoid a second navigation."""
ir = okf.load_ir_projection(bundle_dir)
if ir["project_id"] != project_id:
here (the Layer-2 decision flows via ``run_project``'s own ``verdict_input`` argument).
**The IR projection is OPTIONAL, and the tolerance stops at absence** (S7b søm 1). A base that
HAS one must still agree with the requested id that divergence guard is the existing contract
multi-base dispatch rests on ("den eksisterende fail-fasten blir rutingsnøkkelen"), so loosening
absence must not loosen disagreement. A base WITHOUT one has nothing to check against, and an
ingested corpus is exactly that case.
**The name is unaffected, which is measured rather than assumed.** ``SavingsProposal`` has no
name field, so the projection has never been a name source: ``Project.name`` comes from the
``type: project`` concept's ``title``, with the requested id as the last resort — before and
after this change alike.
``bundle`` reuses an already-navigated bundle to avoid a second navigation."""
ir = okf.load_optional_ir_projection(bundle_dir)
if ir is not None and ir["project_id"] != project_id:
raise ValueError(f"bundle project_id {ir['project_id']!r} != requested {project_id!r}")
nav = bundle if bundle is not None else okf.navigate_bundle(bundle_dir)
project_file = next((f for f in nav.files if f.type == "project"), None)
@ -671,6 +698,32 @@ def collision_notice(collisions: tuple[VerdictCollision, ...]) -> str | None:
return "\n".join(lines)
def unkeyed_verdicts_notice(unkeyed: int) -> str | None:
"""Render the prior verdicts that could NOT reach the hypothesis prompt, or ``None`` when every
one of them could (S7b søm 1).
The measured silence this closes: making the IR projection optional lets an ingested corpus run
the whole loop, but it also removes the pre-hypothesis candidate the Step-1 ExpeL fold is keyed
on. Without a line here, a base holding a dozen prior expert judgements would run and simply not
use them indistinguishable, on stdout and in the artefact alike, from a base that had never
been judged at all. That is the same class of silence ``skipped_links_notice`` exists for.
ONE renderer, taking the already-resolved COUNT rather than a store or a bundle path: a renderer
that re-read either would be a second resolution of the run's own fold, free to disagree with it.
``None`` at zero omission, never an empty row (``mandate.announce``'s rule, the one
``cost_baseline_notice`` and ``skipped_links_notice`` both follow). Zero is the honest positive
statement, and a run that folded everything has nothing to report."""
if unkeyed <= 0:
return None
plural = "" if unkeyed == 1 else "s"
return (
f" Knowledge base: {unkeyed} prior expert verdict{plural} NOT folded into the hypothesis "
"prompt — the base declares no IR projection (validator-input.json), so there is no "
"candidate to key retrieval against"
)
def skipped_links_notice(skipped: tuple[okf.SkippedLink, ...]) -> str | None:
"""Render what the run could NOT read, or ``None`` when every cross-link was followed.
@ -945,12 +998,20 @@ async def run_project(
else None
)
# S7b søm 1: the fold needs a pre-hypothesis CANDIDATE to rank prior verdicts against, and that
# candidate comes from the base's IR projection. A base without one (every ingested corpus) can
# now run — but it cannot key retrieval, and the verdicts it holds would otherwise be dropped in
# silence. Counted here and reported; the fold itself is unchanged when the key exists.
unkeyed_verdicts = 0
if bundle_dir is not None and store is not None and store.verdicts:
expel_query = bundle_candidate_features(bundle_dir)
fewshot = ExpeLContextProvider(
store, expel_query, k=top_k, retriever=ranker
).format_fewshot()
gen_context = f"{fewshot}\n\n{gen_context}"
expel_query = optional_bundle_candidate_features(bundle_dir)
if expel_query is None:
unkeyed_verdicts = len(store.verdicts)
else:
fewshot = ExpeLContextProvider(
store, expel_query, k=top_k, retriever=ranker
).format_fewshot()
gen_context = f"{fewshot}\n\n{gen_context}"
# 5. Structured candidate(s) -> blocking validation on the NUMBERS; token bound = the meter.
# Without a mandate this is the single pre-Trekk-A call, unchanged. With one, every
@ -1157,6 +1218,7 @@ async def run_project(
coverage=coverage,
refinements=tuple(refinements),
skipped_links=skipped_links,
unkeyed_verdicts=unkeyed_verdicts,
)
@ -1735,7 +1797,16 @@ async def run_mandate_across_bundles(
# ONE reading of the base's own project id, used both to ADDRESS the run and to LABEL it.
# A second lookup for the label would be the kø-(p) duplicate free to drift from the value
# the run was actually dispatched with.
project_id = str(okf.load_ir_projection(bundle_dir)["project_id"])
#
# S7b søm 1: the hand-written projection FIRST, the base's DECLARED id as the fallback. The
# precedence is load-bearing in both directions. Declaration-first would re-address every
# existing base whose ``project_id`` differs from its ``bundle_id`` — the file is what those
# bases have always been routed by. File-only was the refusal this seam removes: an ingested
# corpus carries no projection, so it could not be routed at all. ``bundle_id`` is the
# identity every other door already resolves through (S7a-3), so the fallback introduces no
# third notion of what a base is called; ``by_id`` above is that same resolution, reused.
declared_ir = okf.load_optional_ir_projection(bundle_dir)
project_id = str(declared_ir["project_id"]) if declared_ir is not None else bundle_id
# D2: the id of the verdict THIS base minted, taken from ``run_project``'s existing
# ``notify`` seam rather than off the returned ``RunResult``. ``notify`` fires inside the
# capture block, so it is called exactly when a verdict exists (F2: never when nobody
@ -2219,6 +2290,12 @@ def main(argv: list[str] | None = None) -> int:
# when only one is given, so at most one situation reaches this list: both set.
"--decision": args.decision is not None,
"--rationale": args.rationale is not None,
# DEL C, the side-finding økt 82 measured and reported rather than fixed: --mandate is
# OLDER than this partition and never got a row, so ``--report --ledger X --mandate Y``
# dropped the commission in SILENCE — announced nothing, settled nothing, exit 0. The
# F4 class exactly: report mode returns above every dispatch, so an omission here is a
# silent drop and not a refusal.
"--mandate": args.mandate is not None,
}
if any(report_forbidden.values()):
print(
@ -3106,6 +3183,11 @@ def main(argv: list[str] | None = None) -> int:
id_notice = bundle_id_notice(result.provenance.bundle_id_source)
if id_notice is not None:
print(id_notice)
# Full run only, and structurally so: the fold happens BELOW the ``--live-dry-run`` cut, so a
# dry run has nothing to report here (contrast the three notices above, all resolved above it).
fold_notice = unkeyed_verdicts_notice(result.unkeyed_verdicts)
if fold_notice is not None:
print(fold_notice)
# The settlement against the commission (Trekk A4). Empty without a mandate, so an
# un-commissioned run prints exactly what it printed before.
settlement = settle(result.coverage)

View file

@ -503,10 +503,36 @@ def _features_from_ir(ir: dict[str, Any]) -> ProposalFeatures:
def bundle_candidate_features(bundle_dir: str) -> ProposalFeatures:
"""The pre-hypothesis ExpeL query key: the candidate measure's structural features, read from
the OKF bundle's IR projection. Available BEFORE any proposal is generated — which is what lets
Step-1 retrieve prior verdicts and fold them into the hypothesis prompt (målbilde §2 step 1)."""
Step-1 retrieve prior verdicts and fold them into the hypothesis prompt (målbilde §2 step 1).
Fail-fast. Use ``optional_bundle_candidate_features`` where a base without a projection is
legitimate and note that its two consumers answer that absence DIFFERENTLY, on purpose."""
return _features_from_ir(okf.load_ir_projection(bundle_dir))
def optional_bundle_candidate_features(bundle_dir: str) -> ProposalFeatures | None:
"""``bundle_candidate_features`` where a base with no IR projection is legitimate: ``None``
instead of raising (S7b søm 1). An ingested corpus has no hand-written projection, so it has no
pre-hypothesis candidate to key retrieval against a fact about the base, not an error.
Tolerance stops at absence, as everywhere: a projection that exists but is malformed still
raises (``okf.load_optional_ir_projection``'s rule)."""
ir = okf.load_optional_ir_projection(bundle_dir)
return None if ir is None else _features_from_ir(ir)
class VerdictKeyUnavailable(ValueError):
"""A ``type: verdict`` concept declares no structural key AND its bundle has no IR projection to
fall back on (S7b søm 1).
Fail-closed, and the ONE place in this seam where absence is not tolerable. The pre-S3.2
fallback keys such a verdict on the bundle's projection candidate; with no projection there is
no candidate, and minting a key anyway would attach the verdict to a candidate it is not about
precisely the defect S3.2 closes. Validation, never repair (``write_concept_file``'s rule): the
three fields are the author's to declare, and ``promote_verdict`` writes them, so a base grown
by the loop itself is never affected."""
# S3.2: a verdict file MAY carry its own structural key. All three fields or none — see
# ``_features_from_verdict_frontmatter``.
_STRUCTURAL_FRONTMATTER_KEYS = ("affected_codes", "measure_type", "claimed_saving_nok")
@ -618,7 +644,15 @@ def seed_store_from_bundle(bundle_dir: str) -> VerdictStore:
if features is None:
# Read the IR projection lazily: a bundle whose verdicts all carry their own key does
# not need one, and this keeps the fallback path's behaviour byte-identical.
fallback = fallback if fallback is not None else bundle_candidate_features(bundle_dir)
fallback = (
fallback if fallback is not None else optional_bundle_candidate_features(bundle_dir)
)
if fallback is None:
raise VerdictKeyUnavailable(
f"{vf.name}: this verdict declares no structural key "
f"({', '.join(_STRUCTURAL_FRONTMATTER_KEYS)}) and the knowledge base carries no "
"IR projection to fall back on, so there is no candidate to key it to"
)
features = fallback
verdicts.append(
capture_verdict(