feat(visibility): en lenke som ikke ble fulgt sier det - spor + betinget linje (ORDRE 20260821T142704Z)
okf._walk toleret en ulesbar/utenfor-basen lenke uten aa etterlate spor (okf.py:182
"continue # broken link"), og navigate_bundle returnerte kun filene den FANT. En base
der halve innholdet aldri ble lest var derfor umulig aa skille fra en base der de
dokumentene aldri ble skrevet - og toerrkjoeringen sa ingenting.
Toleransen er UROERT: OKF SPEC §4 krever at navigasjonen ikke kaster, og den kaster
fortsatt ikke. Dette er synlighet, ikke en ny nekt.
To tenner (samme form som ordre 20260821T092039Z, synlig uforankring):
1. okf.SkippedLink + Bundle.skipped - strukturert spor, aldri en streng: hvilken fil
lenken sto i, lenketeksten ORDRETT (operatoeren redigerer den teksten, ikke den
resolverte stien), og hvilken av de TO grunnene som gjaldt - outside-bundle (escape,
ofte bevisst) eller missing (inne i basen, ingen lesbar fil, nesten alltid en
skrivefeil). Dedup-grenen (canonical in seen) registreres ALDRI: den er korrekt
navigasjon og det som terminerer sykler.
2. run.skipped_links_notice - EN renderer, tar den alt opploeste tuppelen, returnerer
None naar ingenting ble hoppet over. Printes paa BEGGE flater: --live-dry-run og
den fulle enkeltkjoeringen (en kjoering som PRODUSERTE et forslag fra en halvlest
base er der tausheten kostet mest).
Defaulten er MOTSATT forrige ordres, og forskjellen er innsikten: cost_baseline_anchored
er paakrevd fordi begge defaults lyver, mens en TOM tuppel her er et aerlig positivt
utsagn ("hver lenke ble fulgt") - external_calls-presedensen. Vei-stien navigerer ingen
base, saa tom er bokstavelig sant der ogsaa.
Sporet bor paa RunResult.skipped_links (RUN-nivaa: navigasjonen skjer EN gang per
kjoering, foer noe forslag finnes), aldri paa ProvenanceStamp, som beskriver gaten som
doemte EN kandidat. Ingenting av dette naar bundle_context - derfor er de commons-eide
nav-goldenene byte-uendret, og Bundle( har fortsatt EN konstruksjons-sted (maalt).
Load-bearing MAALT (tests/test_navigation_visibility_loadbearing.py), aatte mutasjoner
alle roede mot HELE suiten + groenn kontroll 897 passed / 5 skipped:
detach missing-registreringen (6 roede) · detach outside-bundle (2) · kollaps de to
grunnene til en (2) · registrer dedup-grenen (1) · renderer returnerer alltid linja
(3, inkl. kontrollene - omisjonen er selv gatet) · detach dry-run-printen (1) ·
detach full-run-printen (1) · konstant tom trace ut av run_project (4).
Docs rettet der de paasto det motsatte: kunnskapsbase-for-en-kjoring.md §5.7 + §6,
presentasjon-bygge-kunnskapsbase.html (steg 8, steg 9, fallgruve 3, avslutningen),
README-ens navigasjonsavsnitt, og CLAUDE.md-ens navigasjons-kontrakt-invariant.
This commit is contained in:
parent
b6397229ca
commit
56c48f6f65
7 changed files with 547 additions and 28 deletions
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -63,6 +63,30 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
|||
commons-eide nav-goldens (`shared/examples/nav-golden-*/expected-read-context.md`, byte-nivå
|
||||
fasit): `test_nav_golden_hierarchy_*` (positiv) + `test_nav_golden_escape_*` (negativ — en gate
|
||||
som bare kan bli grønn beviser ingenting).
|
||||
**Et hopp er TOLERERT, men ikke lenger TAUST (21.08):** `_walk` registrerer hver lenke den ikke
|
||||
fulgte på `Bundle.skipped` — hvilken fil lenken sto i, lenketeksten ORDRETT (operatøren redigerer
|
||||
den teksten, ikke den resolverte stien), og hvilken av de TO grunnene som gjaldt: `outside-bundle`
|
||||
(escape — ofte bevisst, en lenke til nabobasen) eller `missing` (inne i basen, ingen lesbar fil —
|
||||
nesten alltid en skrivefeil). Den tredje grenen, `canonical in seen`, er DEDUP og registreres
|
||||
ALDRI — den er korrekt navigasjon og dét som terminerer sykler; en implementasjon som logget hvert
|
||||
`continue` ville rapportert en frisk base som halvlest. Toleransen er URØRT (§4 krever at det ikke
|
||||
kastes) — dette er synlighet, ikke en ny nekt. **Feltet DEFAULTER til tom tuppel, og det er
|
||||
MOTSATT av `cost_baseline_anchored`s «påkrevd uten default»:** en tom trace er et ærlig POSITIVT
|
||||
utsagn («hver lenke ble fulgt», `external_calls`-presedensen), mens en manglende bool måtte påstå
|
||||
noe om en hendelse og begge påstandene ville iblant vært usanne. Sporet forlater kjøringen på
|
||||
`RunResult.skipped_links` (RUN-nivå — navigasjonen skjer ÉN gang per kjøring, før noe forslag
|
||||
finnes) og `DryRunReport.skipped_links`, aldri på `ProvenanceStamp`, som beskriver gaten som dømte
|
||||
ÉN kandidat. `run.skipped_links_notice` er ENESTE renderer, tar den alt oppløste tuppelen og
|
||||
returnerer `None` når ingenting ble hoppet over (omisjon, aldri tom rad — `announce`-regelen);
|
||||
reason-TOKENET printes rått, så det finnes ingen andre display-vokabular å drifte fra feltet.
|
||||
Ingenting av dette når `bundle_context` (som bygges av `index_summary` + `context_files` alene) —
|
||||
dét er hva som holder nav-goldenene byte-uendret, og `Bundle(` har fortsatt ÉN konstruksjons-sted
|
||||
(`okf.py`, i `navigate_bundle`). Load-bearing MÅLT
|
||||
(`tests/test_navigation_visibility_loadbearing.py`), åtte mutasjoner alle røde mot HELE suiten +
|
||||
grønn kontroll 897/5: detach `missing`-registreringen (6 røde) · detach `outside-bundle` (2 røde) ·
|
||||
kollaps de to grunnene til én (2 røde) · registrer dedup-grenen (1 rød) · renderer returnerer alltid
|
||||
linja (3 røde — inkl. kontrollene, altså er omisjonen selv gatet) · detach dry-run-printen (1 rød) ·
|
||||
detach full-run-printen (1 rød) · konstant tom trace ut av `run_project` (4 røde).
|
||||
- **Kuraterte skrivere kan ikke forfalske ingest-stempelet** (`ingest-spec` §3): `write_concept_file`
|
||||
er repoets ene authoring-primitiv som materialiserer en konseptfil fra CALLER-oppgitt frontmatter,
|
||||
og avviser derfor det KOMPLETTE eierskaps-stempelet (`generated: true` + `ingest_manifest`) med
|
||||
|
|
|
|||
|
|
@ -300,6 +300,13 @@ cross-links, with progressive disclosure — never by keyword retrieval or stuff
|
|||
bundle into a prompt. Query-time retrieval against the bundle is explicitly forbidden by the
|
||||
method spec: it would leak the verdict layer around the learning gate.
|
||||
|
||||
A cross-link that cannot be followed is **tolerated** (OKF SPEC §4 — navigation never raises) but
|
||||
no longer **silent**: each one is recorded on `Bundle.skipped` with the file it was written in, the
|
||||
link text verbatim, and which of the two reasons applied (`missing` — resolves inside the bundle
|
||||
with no readable file there; `outside-bundle` — resolves outside the bundle root). `--live-dry-run`
|
||||
and a full run both print the list, and print nothing when every link was followed — so a bundle
|
||||
that was only half read stops looking like a bundle that was simply smaller.
|
||||
|
||||
## AI-first, humans on top
|
||||
|
||||
A traditional wiki is built for *people* — optimized for humans finding and reading
|
||||
|
|
|
|||
|
|
@ -496,9 +496,25 @@ modellene er oppløst, takene er satt, og ingen ekstern tjeneste vil bli kontakt
|
|||
skjer før kuttet, uten et eneste modellkall (VERIFISERT: `run.py:513-516` ligger før
|
||||
dry-run-returen på `:565-588`).
|
||||
|
||||
**Hva `OK` sier fra om:** en lenke kjøringen ikke klarte å følge. Tørrkjøringen skriver da én
|
||||
blokk under `OK`-linja — én linje per lenke, med filen lenken sto i, lenketeksten ordrett, og
|
||||
hvorfor: `missing` (målet ligger inne i basen, men det er ingen lesbar fil der — nesten alltid en
|
||||
skrivefeil i lenken) eller `outside-bundle` (målet peker ut av basen). Er alle lenkene fulgt,
|
||||
skrives ingen linje i det hele tatt. Målt på en base med begge feilene:
|
||||
|
||||
```
|
||||
Knowledge base: 2 cross-link(s) NOT followed — the agents never read the document(s) behind them:
|
||||
- index.md -> metodikk-som-aldri-ble-skrevet.md (missing)
|
||||
- index.md -> ../nabobase/index.md (outside-bundle)
|
||||
```
|
||||
|
||||
Selve toleransen er uendret: en brutt lenke stopper ikke kjøringen (OKF SPEC §4 krever det). Det
|
||||
som er nytt er at den ikke lenger er taus — før dette var «dokumentet ble aldri skrevet» og «lenken
|
||||
til det var feil» umulig å skille utenfra. Den samme blokka skrives på en full kjøring.
|
||||
|
||||
**Hva `OK` ikke beviser:** at `cost-baseline.json` *finnes* (se [§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)
|
||||
— sjekk det med `ls`), at lenkene i `index.md` når alle filene (en brutt lenke hoppes stille over),
|
||||
eller at innholdet er godt. `--docs-dir` må oppgis, men leses ikke på bundle-stien (VERIFISERT:
|
||||
— sjekk det med `ls`), at filene faktisk er lenket fra `index.md` (en fil ingen lenker til er
|
||||
unåbar, og da finnes det ingen lenke å rapportere), eller at innholdet er godt. `--docs-dir` må oppgis, men leses ikke på bundle-stien (VERIFISERT:
|
||||
`run.py:513-530` bruker bare `bundle_dir`; `:1504` krever flagget likevel). Pek den på samme
|
||||
mappe.
|
||||
|
||||
|
|
@ -538,10 +554,11 @@ så dokumentet ikke lover mer enn det som kan leveres.
|
|||
tilnærming i bestillingen (VERIFISERT: `run.py:634-638`; én `expel_query` for hele
|
||||
kjøringen). En bestilling med to tilnærminger mot to ulike kostlinjer får tidligere dommer
|
||||
hentet for den kandidaten `validator-input.json` beskriver.
|
||||
- **Det finnes ingen «er basen klar?»-kommando.** `--live-dry-run` beviser at basen *åpner*, og
|
||||
sier nå fra hvis `cost-baseline.json` mangler — men ingenting om brutte lenker, og ingenting om
|
||||
innholdets kvalitet. `preflight` sjekker Azure-konfigurasjon, ikke basen (VERIFISERT:
|
||||
`preflight.py` docstring).
|
||||
- **Det finnes ingen «er basen klar?»-kommando.** `--live-dry-run` beviser at basen *åpner*, sier
|
||||
fra hvis `cost-baseline.json` mangler, og lister lenkene den ikke klarte å følge
|
||||
([§5.7](#57-kjøreklar-sjekken)) — men ingenting om innholdets kvalitet, og ingenting om filer
|
||||
ingen lenker til (de er unåbare, så det finnes ingen brutt lenke å rapportere). `preflight`
|
||||
sjekker Azure-konfigurasjon, ikke basen (VERIFISERT: `preflight.py` docstring).
|
||||
- **Uforankret kjøring er synlig, men ikke summert.** Feltet og linja finnes per kjøring
|
||||
([§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)); det finnes ingen rapport som teller opp
|
||||
hvor mange kjøringer i et porteføljepass som gikk uforankret.
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@
|
|||
du vil at det skal se.</p>
|
||||
<div class="done"><b>Ferdig når</b>Hvert dokument du har laget er lenket. Et dokument uten lenke
|
||||
eksisterer ikke, uansett hvor det ligger. Og trenger du fagstoff fra en annen base: kopier det
|
||||
inn — lenker ut av basen følges aldri, uten feilmelding.</div>
|
||||
inn — lenker ut av basen følges aldri, men tørrkjøringen lister dem, så du ser at det skjedde.</div>
|
||||
</section>
|
||||
|
||||
<section class="slide">
|
||||
|
|
@ -323,15 +323,19 @@
|
|||
stopper der — den koster ingenting, og kan kjøres hver gang basen endres.</p>
|
||||
<table>
|
||||
<tr><th>Tørrkjøringen beviser</th><th>Den beviser ikke</th></tr>
|
||||
<tr><td>at basen åpner</td><td>at lenkene når alle dokumentene</td></tr>
|
||||
<tr><td>at navnet stemmer overalt</td><td>at innholdet er godt</td></tr>
|
||||
<tr><td>at kostnadsgrunnlaget er lesbart</td><td>at forslagene blir kloke</td></tr>
|
||||
<tr><td>at basen åpner</td><td>at innholdet er godt</td></tr>
|
||||
<tr><td>at navnet stemmer overalt</td><td>at forslagene blir kloke</td></tr>
|
||||
<tr><td>at kostnadsgrunnlaget er lesbart</td><td>at alt du laget faktisk er lenket</td></tr>
|
||||
<tr><td>hvilke lenker den ikke klarte å følge</td><td>—</td></tr>
|
||||
<tr><td>at ingen ekstern tjeneste kontaktes</td><td>—</td></tr>
|
||||
</table>
|
||||
<p>Mangler kostnadsgrunnlaget, sier tørrkjøringen fra. Er en lenke brutt, hoppes dokumentet stille
|
||||
over — det er den ene feilen du må lete etter selv.</p>
|
||||
<div class="done"><b>Ferdig når</b>Tørrkjøringen er grønn, og du har lest gjennom listen over
|
||||
dokumenter den faktisk fant.</div>
|
||||
<p>Mangler kostnadsgrunnlaget, sier tørrkjøringen fra. Er en lenke brutt, stopper den fortsatt
|
||||
ikke kjøringen — men den skrives ut, med dokumentet den pekte på og hvorfor den ikke ble fulgt
|
||||
(<code>missing</code> = ingen fil der, <code>outside-bundle</code> = peker ut av basen). Er alle
|
||||
lenker fulgt, står det ingenting. Den feilen den fortsatt ikke kan se, er dokumentet
|
||||
<em>ingen</em> lenker til — da finnes det ingen lenke å rapportere.</p>
|
||||
<div class="done"><b>Ferdig når</b>Tørrkjøringen er grønn, den lister ingen lenker den ikke fulgte,
|
||||
og du har lest gjennom listen over dokumenter den faktisk fant.</div>
|
||||
</section>
|
||||
|
||||
<section class="slide">
|
||||
|
|
@ -416,8 +420,10 @@
|
|||
dømmer bare mot tall forslaget fant på selv. Dette er den dyreste feilen, fordi den er usynlig.</li>
|
||||
<li><strong>Gjennomførte tiltak står ikke i basen.</strong> Systemet foreslår dem på nytt, og
|
||||
besparelsen dobbelttelles.</li>
|
||||
<li><strong>Dokumenter uten lenke.</strong> De leses aldri, og ingen feilmelding sier fra.
|
||||
Symptomet er at forslagene virker uinformerte uten at noen skjønner hvorfor.</li>
|
||||
<li><strong>Dokumenter uten lenke.</strong> De leses aldri, og ingen feilmelding sier fra —
|
||||
for det finnes ingen lenke å klage på. Er lenken der, men feil, sier tørrkjøringen nå fra;
|
||||
er den aldri skrevet, er dokumentet usynlig. Symptomet er at forslagene virker uinformerte
|
||||
uten at noen skjønner hvorfor.</li>
|
||||
<li><strong>For mye materiale.</strong> Alt som lenkes leses i sin helhet. Ti sider støy koster
|
||||
like mye oppmerksomhet som ti sider substans.</li>
|
||||
<li><strong>Utledede tall.</strong> Et tall ingen kan peke på en kilde for, forurenser alt som
|
||||
|
|
@ -433,8 +439,9 @@
|
|||
kostnadstallene og å få en fagperson til å skrive ned rammene sine.</li>
|
||||
<li><strong>Det finnes ingen automatikk</strong> som lager basen av regnearkene deres. Det er
|
||||
håndarbeid, og det er derfor det tar tid.</li>
|
||||
<li><strong>Det finnes ingen «er basen klar»-knapp.</strong> Tørrkjøringen er det nærmeste, og
|
||||
den sier ingenting om brutte lenker eller om innholdet er godt.</li>
|
||||
<li><strong>Det finnes ingen «er basen klar»-knapp.</strong> Tørrkjøringen er det nærmeste: den
|
||||
sier fra om kostnadsgrunnlaget mangler og lister lenkene den ikke klarte å følge, men den sier
|
||||
ingenting om et dokument ingen har lenket til, og ingenting om innholdet er godt.</li>
|
||||
<li><strong>Systemet dømmer ikke.</strong> Det foreslår og kontrollerer regnestykket.
|
||||
Vurderingen av om tiltaket er klokt, gjør fagpersonen — og den vurderingen er det basen lærer av.</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ fields. A target that fails to resolve for ANY reason (missing file, invalid pat
|
|||
escape) is silently skipped, never raised. Path-safety reuses ``retrieval.safe_resolve`` (also pure
|
||||
stdlib): each cross-link is canonicalised and boundary-checked against the bundle dir, fail-closed
|
||||
— the SOLE in-/out-of-bundle test.
|
||||
|
||||
Skipped is not SILENT, though: every link the walk could not follow is recorded on
|
||||
``Bundle.skipped`` as a ``SkippedLink`` (which file it was written in, the link text verbatim, and
|
||||
which of the two reasons applied). The tolerance is unchanged — nothing raises — but a bundle whose
|
||||
other half was never reached is no longer indistinguishable from one where those documents were
|
||||
never written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -28,7 +34,7 @@ import posixpath
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from portfolio_optimiser.ir import CostBaseline
|
||||
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
|
||||
|
|
@ -94,12 +100,55 @@ class BundleFile:
|
|||
body: str
|
||||
|
||||
|
||||
#: Why navigation did not follow a cross-link. TWO values, because the two mean different things
|
||||
#: to whoever has to fix the bundle: ``outside-bundle`` is a target that resolves OUTSIDE the bundle
|
||||
#: root (frequently a deliberate link to a neighbouring base), ``missing`` is a target that resolves
|
||||
#: INSIDE it with no readable file there (almost always a typo in the link). Collapsing them into
|
||||
#: one "skipped" would answer neither question. De-duplication is NOT among them: a repeated link
|
||||
#: and a cycle are correct navigation, never a skip.
|
||||
SkipReason = Literal["outside-bundle", "missing"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedLink:
|
||||
"""One cross-link the walk did NOT follow, and why.
|
||||
|
||||
STRUCTURED rather than a rendered string, for the reason ``BudgetExceeded`` carries
|
||||
``kind``/``limit``/``observed`` as fields (kø-(y)): "which document is missing" and "why is it
|
||||
missing" are two separate operative questions, and a caller that has to re-parse prose to tell
|
||||
them apart has been handed a diagnostic it cannot act on.
|
||||
|
||||
``target`` is the link text VERBATIM as written in the source file, never the resolved path: the
|
||||
operator fixing the bundle edits that text, and a normalised form would send them looking for a
|
||||
string their file does not contain."""
|
||||
|
||||
#: Bundle-relative name of the file the link was written in.
|
||||
from_file: str
|
||||
#: The link target exactly as it appears in that file.
|
||||
target: str
|
||||
reason: SkipReason
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bundle:
|
||||
"""A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves."""
|
||||
"""A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves — and, in
|
||||
``skipped``, every cross-link that did not.
|
||||
|
||||
``skipped`` DEFAULTS to the empty tuple, and the default is the honest reading rather than a
|
||||
convenience: an empty trace is a positive statement ("every cross-link was followed"), in the
|
||||
same class as ``ProvenanceStamp.external_calls`` ("nothing outside this process was contacted").
|
||||
That is the opposite of ``ProvenanceStamp.cost_baseline_anchored``, which is REQUIRED precisely
|
||||
because both of its defaults would lie. The difference is what each absent value would assert:
|
||||
a missing bool has to claim something about an event, while a missing trace asserts only that
|
||||
the event list is empty — which is exactly what a construction with no skips means."""
|
||||
|
||||
dir: str
|
||||
files: tuple[BundleFile, ...]
|
||||
#: Every link navigation could not follow, in walk order. Read by ``run`` to render the one line
|
||||
#: a run prints about its own reachability; NEVER read by ``bundle_context``, whose rendering is
|
||||
#: built from ``index_summary`` + ``context_files`` alone — which is what keeps the commons-owned
|
||||
#: nav-golden fasit byte-identical.
|
||||
skipped: tuple[SkippedLink, ...] = ()
|
||||
|
||||
@property
|
||||
def index_summary(self) -> str:
|
||||
|
|
@ -165,29 +214,54 @@ def _resolve_target(bundle_dir: str, from_name: str, target: str) -> tuple[str,
|
|||
return None
|
||||
|
||||
|
||||
def _walk(bundle_dir: str, current: BundleFile, files: list[BundleFile], seen: set[str]) -> None:
|
||||
def _walk(
|
||||
bundle_dir: str,
|
||||
current: BundleFile,
|
||||
files: list[BundleFile],
|
||||
seen: set[str],
|
||||
skipped: list[SkippedLink],
|
||||
) -> None:
|
||||
"""Follow ``current``'s cross-links depth-first in first-seen order, appending each newly
|
||||
reached file and recursing into it. De-duplication is on the CANONICAL RESOLVED path (so
|
||||
``./a.md``, ``a.md`` and ``/a.md`` are one entry), which is also what terminates cycles."""
|
||||
``./a.md``, ``a.md`` and ``/a.md`` are one entry), which is also what terminates cycles.
|
||||
|
||||
A link that cannot be followed is still SKIPPED, never raised (OKF §4) — the tolerance is the
|
||||
spec — but it is now RECORDED in ``skipped``, with the reason distinguishing the two cases.
|
||||
The dedup branch records NOTHING: a repeated link and a cycle are correct navigation, and an
|
||||
implementation that logged every ``continue`` would report a healthy bundle as half-unread.
|
||||
|
||||
A caller-owned accumulator rather than a return value, for the reason ``generate``'s
|
||||
parse-failure sink is one: the recursion is depth-first over an unbounded tree, so every frame
|
||||
appends into the SAME list and the walk's shape stays unchanged."""
|
||||
for target in _LINK_RE.findall(current.body):
|
||||
resolved = _resolve_target(bundle_dir, current.name, target)
|
||||
if resolved is None:
|
||||
# The target left the bundle. Often deliberate (a link to a neighbouring base), so it is
|
||||
# reported rather than refused — the tolerance is unchanged.
|
||||
skipped.append(
|
||||
SkippedLink(from_file=current.name, target=target, reason="outside-bundle")
|
||||
)
|
||||
continue
|
||||
rel, canonical = resolved
|
||||
if canonical in seen:
|
||||
continue
|
||||
continue # de-duplication / cycle termination: correct navigation, NOT a skip
|
||||
seen.add(canonical)
|
||||
linked = _load_file(bundle_dir, rel)
|
||||
if linked is None:
|
||||
continue # broken link: tolerated, never raised (OKF §4)
|
||||
# In-bundle, but nothing readable is there: broken link, tolerated, never raised (§4).
|
||||
# Recorded once per resolved target — the ``seen`` entry above absorbs repeats.
|
||||
skipped.append(SkippedLink(from_file=current.name, target=target, reason="missing"))
|
||||
continue
|
||||
files.append(linked)
|
||||
_walk(bundle_dir, linked, files, seen)
|
||||
_walk(bundle_dir, linked, files, seen, skipped)
|
||||
|
||||
|
||||
def navigate_bundle(bundle_dir: str) -> Bundle:
|
||||
"""Navigate the OKF bundle from ``index.md``: parse the root index, then follow intra-bundle
|
||||
``.md`` cross-links RECURSIVELY, depth-first in first-seen link order, reading each reached
|
||||
file's frontmatter + body. Fully deterministic. Broken / escaping links are skipped (§4).
|
||||
file's frontmatter + body. Fully deterministic. Broken / escaping links are skipped (§4) — and
|
||||
RECORDED on the returned ``Bundle.skipped``, so "this document was never written" and "the link
|
||||
to it was wrong" stop looking identical from the outside.
|
||||
|
||||
Navigation follows LINKS ONLY — a directory is never enumerated. Hence the missing-``index.md``
|
||||
error binds the bundle ROOT alone (a bundle has no entry point without it); an intermediate
|
||||
|
|
@ -197,10 +271,11 @@ def navigate_bundle(bundle_dir: str) -> Bundle:
|
|||
if index is None:
|
||||
raise ValueError(f"OKF bundle has no readable {_INDEX_NAME}: {bundle_dir!r}")
|
||||
files: list[BundleFile] = [index]
|
||||
skipped: list[SkippedLink] = []
|
||||
root = _resolve_target(bundle_dir, _INDEX_NAME, _INDEX_NAME)
|
||||
seen = {root[1]} if root is not None else set()
|
||||
_walk(bundle_dir, index, files, seen)
|
||||
return Bundle(dir=bundle_dir, files=tuple(files))
|
||||
_walk(bundle_dir, index, files, seen, skipped)
|
||||
return Bundle(dir=bundle_dir, files=tuple(files), skipped=tuple(skipped))
|
||||
|
||||
|
||||
def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str:
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ class RunResult:
|
|||
#: what ``_evaluate_mandate`` deliberately avoids. It defaults, so every existing constructor
|
||||
#: call is unaffected (mirrors ``coverage``).
|
||||
refinements: tuple[Rejection, ...] = ()
|
||||
#: Every cross-link the bundle navigation could not follow. A RUN-level fact, carried here and
|
||||
#: NOT on ``provenance``: navigation happens ONCE per run, before any proposal exists, and the
|
||||
#: same walk backs every refinement attempt — whereas ``ProvenanceStamp.cost_baseline_anchored``
|
||||
#: 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, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -177,6 +183,12 @@ class DryRunReport:
|
|||
#: because a dry run stops before any proposal exists, so there is no stamp to read it off —
|
||||
#: and this surface is precisely where the un-anchored case was measured to be silent.
|
||||
cost_baseline_anchored: bool
|
||||
#: Every cross-link the bundle navigation could not follow (``okf.Bundle.skipped``). EMPTY is a
|
||||
#: positive statement — "every cross-link was followed" — which is why it DEFAULTS, unlike
|
||||
#: ``cost_baseline_anchored`` above: a missing bool would have to claim something about an event
|
||||
#: (and both claims would sometimes be false), while a missing trace asserts only that the event
|
||||
#: list is empty. The road path navigates no bundle, so empty is literally true there too.
|
||||
skipped_links: tuple[okf.SkippedLink, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -460,6 +472,39 @@ def cost_baseline_notice(anchored: bool) -> str | None:
|
|||
return None if anchored else _UNANCHORED_NOTICE
|
||||
|
||||
|
||||
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.
|
||||
|
||||
The measured silence this closes: ``okf._walk`` tolerates an unfollowable link exactly as OKF
|
||||
SPEC §4 requires (skip, never raise) — correct, and unchanged here — but it left no trace, so a
|
||||
knowledge base whose other half was never reached looked identical to one where those documents
|
||||
were never written, and ``--live-dry-run`` exited 0 over both.
|
||||
|
||||
ONE renderer with N callsites, never N copies of the wording (kø-(p)), and it takes the
|
||||
already-resolved trace rather than a bundle path: a renderer that re-navigated the bundle would
|
||||
be a second resolution of the same walk, free to disagree with the run it describes. Both
|
||||
callsites read it off the value ``run_project`` returned from its ONE
|
||||
``okf.navigate_bundle`` call.
|
||||
|
||||
``None`` when the trace is empty — omission, never an empty row (``mandate.announce``'s rule,
|
||||
the same one ``cost_baseline_notice`` follows). A run that reached everything has nothing to
|
||||
report.
|
||||
|
||||
The per-link line prints the reason TOKEN itself rather than a prose translation of it: a second
|
||||
display vocabulary keyed off ``SkipReason`` would be the duplicate free to drift, and the token
|
||||
is already the operative word ("missing" vs "outside-bundle"). English, like every other line
|
||||
this CLI prints; the Norwegian explanation belongs in
|
||||
``docs/kunnskapsbase-for-en-kjoring.md``, next to the domain expert."""
|
||||
if not skipped:
|
||||
return None
|
||||
lines = [
|
||||
f" Knowledge base: {len(skipped)} cross-link(s) NOT followed — "
|
||||
"the agents never read the document(s) behind them:"
|
||||
]
|
||||
lines += [f" - {s.from_file} -> {s.target} ({s.reason})" for s in skipped]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def run_project(
|
||||
project_id: str,
|
||||
profile: Profile | str = Profile.LOCAL,
|
||||
|
|
@ -555,6 +600,9 @@ async def run_project(
|
|||
# dimension=None keeps the full context, byte-identical to before.
|
||||
context = okf.bundle_context(bundle, dimension=dimension.id if dimension else None)
|
||||
citations = bundle_citations(bundle)
|
||||
# What the navigation could NOT reach, taken from the run's ONE walk. The road path below
|
||||
# navigates no bundle at all, so its empty tuple is literally true rather than a stand-in.
|
||||
skipped_links: tuple[okf.SkippedLink, ...] = bundle.skipped
|
||||
debate_tools: list[Any] = []
|
||||
else:
|
||||
project = _project_by_id(project_id)
|
||||
|
|
@ -562,6 +610,7 @@ async def run_project(
|
|||
chunks = retrieve_chunks("cost saving measure", docs_dir, top_k)
|
||||
citations = [chunk_dict_to_citation(c) for c in chunks]
|
||||
context = "\n".join(c["snippet"] for c in chunks)
|
||||
skipped_links = ()
|
||||
debate_tools = [make_retrieval_tool(docs_dir, top_k=top_k)]
|
||||
|
||||
# Trekk B2 (krav 3): configured MCP servers become tools the AGENTS can call during the debate.
|
||||
|
|
@ -623,6 +672,7 @@ async def run_project(
|
|||
max_tokens=max_tokens,
|
||||
top_k=top_k,
|
||||
cost_baseline_anchored=baseline is not None,
|
||||
skipped_links=skipped_links,
|
||||
)
|
||||
# The MCP lifecycle (Trekk B2): entered HERE, after the dry-run cut above, so a dry run never
|
||||
# opens a connection — its promise to stop before the first call covers egress too. Constructed
|
||||
|
|
@ -861,6 +911,7 @@ async def run_project(
|
|||
checker_verdict=checker_decision,
|
||||
coverage=coverage,
|
||||
refinements=tuple(refinements),
|
||||
skipped_links=skipped_links,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1833,6 +1884,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
notice = cost_baseline_notice(report.cost_baseline_anchored)
|
||||
if notice is not None:
|
||||
print(notice)
|
||||
# The second measured silence on this surface: a bundle with an unfollowable cross-link
|
||||
# dry-ran to rc 0 with nothing said, so a half-read base looked exactly like a small one.
|
||||
nav_notice = skipped_links_notice(report.skipped_links)
|
||||
if nav_notice is not None:
|
||||
print(nav_notice)
|
||||
return 0
|
||||
|
||||
try:
|
||||
|
|
@ -1875,6 +1931,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
notice = cost_baseline_notice(result.provenance.cost_baseline_anchored)
|
||||
if notice is not None:
|
||||
print(notice)
|
||||
# Same renderer on the full run, and deliberately so: a run that PRODUCED a proposal from a
|
||||
# half-read base is where the silence cost the most — the dry run at least produced nothing.
|
||||
nav_notice = skipped_links_notice(result.skipped_links)
|
||||
if nav_notice is not None:
|
||||
print(nav_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)
|
||||
|
|
|
|||
328
tests/test_navigation_visibility_loadbearing.py
Normal file
328
tests/test_navigation_visibility_loadbearing.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""A knowledge base that was only HALF read SAYS so — a structured trace, and one line when it fired.
|
||||
|
||||
``okf._walk`` tolerates a cross-link it cannot follow, exactly as OKF SPEC §4 requires: it skips and
|
||||
never raises. **That tolerance is correct and is not what this file changes.** What it changes is
|
||||
that the skip left NO TRACE. ``navigate_bundle`` returned only the files it FOUND, so a base whose
|
||||
other half was never reached is indistinguishable from a base where those documents were never
|
||||
written — and ``--live-dry-run`` exited 0 in silence over both.
|
||||
|
||||
Measured (session 51, 2026-08-21, against ``b639722``): ``okf.py:182`` was
|
||||
``continue # broken link: tolerated, never raised (OKF §4)``, ``Bundle`` carried ``dir`` and
|
||||
``files`` alone, and ``grep -rn "Bundle(" src tests`` returned exactly ONE construction site
|
||||
(``okf.py:203``, inside ``navigate_bundle``) — so the trace has a single producer and cannot be
|
||||
forged by a second constructor.
|
||||
|
||||
Same defect class and same SHAPE as order ``20260821T092039Z`` (visible un-anchoring, delivered in
|
||||
``156312c``): a tolerance that is right, plus an absence that is not.
|
||||
|
||||
Two teeth:
|
||||
|
||||
1. ``okf.SkippedLink`` + ``Bundle.skipped`` — a STRUCTURED trace, never a string, because "which
|
||||
document is missing" and "why" are two different operative questions (kø-(y)). ``_walk`` has TWO
|
||||
distinct skip reasons and they mean different things: ``outside-bundle`` (the target resolves
|
||||
outside the bundle root — often a deliberate link to a neighbouring base) and ``missing`` (it
|
||||
resolves INSIDE and no readable file is there — almost always a typo in the link). The third
|
||||
branch, ``canonical in seen``, is DE-DUPLICATION: correct behaviour, also what terminates cycles,
|
||||
and never a skip — arm (d) exists to keep it out of the trace.
|
||||
2. ``run.skipped_links_notice`` — ONE renderer, taking the already-resolved value, returning ``None``
|
||||
when nothing was skipped (omission, never an empty row — ``mandate.announce``'s rule, reused by
|
||||
``cost_baseline_notice``).
|
||||
|
||||
**The default DIFFERS from the previous order's, and that difference is the insight.**
|
||||
``ProvenanceStamp.cost_baseline_anchored`` is REQUIRED with no default because both defaults lie:
|
||||
``True`` claims an anchoring that may never have happened, ``False`` under-claims a real one. Here
|
||||
the honest reading is the opposite: an EMPTY tuple is a positive statement — "every cross-link was
|
||||
followed" — in the same class as ``ProvenanceStamp.external_calls`` ("nothing outside this process
|
||||
was contacted"). A caller that constructs a ``Bundle`` without a trace is not withholding a fact; it
|
||||
is stating one. So ``skipped`` defaults to ``()``, and the road path (which navigates nothing) is
|
||||
honestly empty rather than dishonestly required to invent a value.
|
||||
|
||||
Arms:
|
||||
(a) a MISSING target yields exactly one entry, carrying its own reason;
|
||||
(b) an ESCAPING target yields exactly one entry, carrying the OTHER reason — asserted on the
|
||||
structured ``reason`` field, never on shared prose (the 08-09 class), and the two reasons are
|
||||
asserted to DIFFER so a single collapsed reason cannot pass both;
|
||||
(c) an intact base yields an EMPTY tuple and NO line (the control — without it (a) passes on a
|
||||
constant), with the navigation proved to have happened first;
|
||||
(d) the dedup branch (a repeated link, and a cycle) yields NO entry at all;
|
||||
(e) both CLI surfaces carry it — ``--live-dry-run`` and the full run — plus the typed carriers.
|
||||
|
||||
The commons-owned ``nav-golden-escape`` fasit is a free independent witness that the SEMANTICS did
|
||||
not move: every link but one escapes there, and it must still render byte-identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import SyntheticUsageChatClient
|
||||
|
||||
from portfolio_optimiser import okf, run
|
||||
from portfolio_optimiser.okf import SkippedLink
|
||||
from portfolio_optimiser.run import DryRunReport, RunResult, run_project, skipped_links_notice
|
||||
|
||||
_DATA = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "data" / "bundles"
|
||||
INTACT_BUNDLE = _DATA / "bygg-energi-mikro-a"
|
||||
|
||||
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
|
||||
|
||||
#: A reply the pre-amendment fixture's un-anchored gate accepts far enough to produce an outcome.
|
||||
_REPLY = json.dumps(
|
||||
{
|
||||
"measure": "LED-retrofit",
|
||||
"affected_items": [{"code": "ENERGI-TOTAL-EL", "quantity": 180000, "unit_cost": 1.0}],
|
||||
"claimed_saving_nok": 30000,
|
||||
}
|
||||
)
|
||||
|
||||
#: The phrase the notice carries and an intact run cannot: an intact run prints NO line at all.
|
||||
_SENTINEL = "NOT followed"
|
||||
|
||||
#: The link an arm appends to a COPY of the fixture index — a name nothing in the bundle provides.
|
||||
_DANGLING = "fantes-aldri.md"
|
||||
|
||||
|
||||
def _factory(reply: str = _REPLY):
|
||||
def factory(role: str):
|
||||
return SyntheticUsageChatClient(default_reply=reply)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Hermetic env: the operator's Foundry overrides must not reach the CLI arms."""
|
||||
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
||||
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
||||
|
||||
|
||||
def _write(path: Path, body: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def _bundle_copy(tmp_path: Path, *, dangling: bool) -> Path:
|
||||
"""A throwaway copy of the repo-local fixture — the shipped bundle is never touched. With
|
||||
``dangling`` the root index gains ONE link to a file that does not exist."""
|
||||
dest = tmp_path / "bundle"
|
||||
shutil.copytree(INTACT_BUNDLE, dest)
|
||||
if dangling:
|
||||
index = dest / "index.md"
|
||||
index.write_text(
|
||||
index.read_text(encoding="utf-8") + f"\n- [Borte]({_DANGLING})\n", encoding="utf-8"
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
# --- Arm (a): a MISSING target is recorded, with its own reason -----------------------------------
|
||||
|
||||
|
||||
def test_missing_target_is_recorded_as_one_entry(tmp_path) -> None:
|
||||
"""RED: the base still navigates and still raises nothing (§4 tolerance UNTOUCHED) — but the
|
||||
link it could not follow is now on the bundle, naming the file it was written in, the link text
|
||||
VERBATIM, and why. Detach the recording and the walk goes silent again."""
|
||||
_write(tmp_path / "index.md", "---\ntype: index\n---\n\n- [Borte](fantes-aldri.md)\n")
|
||||
bundle = okf.navigate_bundle(str(tmp_path))
|
||||
assert [f.name for f in bundle.files] == ["index.md"] # tolerated, not raised
|
||||
assert bundle.skipped == (
|
||||
SkippedLink(from_file="index.md", target="fantes-aldri.md", reason="missing"),
|
||||
)
|
||||
|
||||
|
||||
# --- Arm (b): an ESCAPING target is recorded, with the OTHER reason -------------------------------
|
||||
|
||||
|
||||
def test_escaping_target_is_recorded_with_a_different_reason(tmp_path) -> None:
|
||||
"""The two skip branches mean different things and must not be collapsed: this target EXISTS,
|
||||
one level up, and is refused because it is outside the bundle root — not because it is absent.
|
||||
Asserted on the structured ``reason``, never on prose the two branches could share."""
|
||||
_write(tmp_path / "outside.md", "---\ntype: reference\n---\n\nSHOULD NOT BE READ\n")
|
||||
root = tmp_path / "bundle"
|
||||
_write(root / "index.md", "---\ntype: index\n---\n\n- [Ute](../outside.md)\n")
|
||||
bundle = okf.navigate_bundle(str(root))
|
||||
assert [f.name for f in bundle.files] == ["index.md"]
|
||||
assert bundle.skipped == (
|
||||
SkippedLink(from_file="index.md", target="../outside.md", reason="outside-bundle"),
|
||||
)
|
||||
|
||||
|
||||
def test_the_two_reasons_are_distinct_values(tmp_path) -> None:
|
||||
"""A single collapsed reason would pass BOTH arms above if they were read in isolation. This
|
||||
pins the discrimination itself: same shape of bundle, two skips, two different values."""
|
||||
_write(tmp_path / "outside.md", "---\ntype: reference\n---\n\nx\n")
|
||||
root = tmp_path / "bundle"
|
||||
_write(
|
||||
root / "index.md",
|
||||
"---\ntype: index\n---\n\n- [Borte](fantes-aldri.md)\n- [Ute](../outside.md)\n",
|
||||
)
|
||||
reasons = [s.reason for s in okf.navigate_bundle(str(root)).skipped]
|
||||
assert len(reasons) == 2
|
||||
assert reasons[0] != reasons[1]
|
||||
|
||||
|
||||
# --- Arm (c): the control — an intact base records nothing and prints nothing ---------------------
|
||||
|
||||
|
||||
def test_intact_bundle_records_nothing(tmp_path) -> None:
|
||||
"""Causality control. Without it arm (a) would pass on an implementation that records a constant
|
||||
entry for every base. The navigation is proved to have HAPPENED first (two files reached), so
|
||||
the empty trace is a measured absence rather than a base that was never walked."""
|
||||
_write(tmp_path / "index.md", "---\ntype: index\n---\n\n- [A](a.md)\n")
|
||||
_write(tmp_path / "a.md", "---\ntype: project\n---\n\nA body\n")
|
||||
bundle = okf.navigate_bundle(str(tmp_path))
|
||||
assert [f.name for f in bundle.files] == ["index.md", "a.md"] # the walk really ran
|
||||
assert bundle.skipped == ()
|
||||
assert skipped_links_notice(bundle.skipped) is None
|
||||
|
||||
|
||||
# --- Arm (d): de-duplication is NOT a skip --------------------------------------------------------
|
||||
|
||||
|
||||
def test_dedup_and_cycles_produce_no_entry(tmp_path) -> None:
|
||||
"""``canonical in seen`` is correct behaviour, not a failure: it is what makes a repeated link
|
||||
one entry and what terminates a cycle. An implementation that recorded every ``continue`` would
|
||||
report a healthy base as half-unread. Both forms are exercised: ``a.md`` is linked twice from
|
||||
the index (once as ``./a.md``, deduped on the RESOLVED path) and links back to the index."""
|
||||
_write(tmp_path / "index.md", "---\ntype: index\n---\n\n- [A](a.md)\n- [A again](./a.md)\n")
|
||||
_write(tmp_path / "a.md", "---\ntype: project\n---\n\nA body\n\n- [Back](index.md)\n")
|
||||
bundle = okf.navigate_bundle(str(tmp_path))
|
||||
assert [f.name for f in bundle.files] == ["index.md", "a.md"] # deduped + cycle terminated
|
||||
assert bundle.skipped == ()
|
||||
|
||||
|
||||
# --- Arm (e): the renderer and both CLI surfaces --------------------------------------------------
|
||||
|
||||
|
||||
def test_notice_is_rendered_only_when_something_was_skipped() -> None:
|
||||
"""One renderer, two branches sharing NO wording: a non-empty trace returns a line carrying the
|
||||
sentinel AND the operative facts; an empty trace returns ``None`` (omitted, never an empty row).
|
||||
The reason token printed is the STRUCTURED value itself, so there is no second display
|
||||
vocabulary free to drift from the field (kø-(p))."""
|
||||
rendered = skipped_links_notice(
|
||||
(SkippedLink(from_file="index.md", target="fantes-aldri.md", reason="missing"),)
|
||||
)
|
||||
assert rendered is not None
|
||||
assert _SENTINEL in rendered
|
||||
assert "index.md" in rendered
|
||||
assert "fantes-aldri.md" in rendered
|
||||
assert "missing" in rendered
|
||||
assert skipped_links_notice(()) is None
|
||||
|
||||
|
||||
async def test_dry_run_report_carries_the_trace(tmp_path, fresh_store) -> None:
|
||||
"""The dry-run type is the carrier for the surface the order measured: a run that stops before
|
||||
the first model call already knows what it could not read."""
|
||||
assert "skipped_links" in DryRunReport.__dataclass_fields__
|
||||
bundle = _bundle_copy(tmp_path, dangling=True)
|
||||
report = await run_project(
|
||||
"BYGG-ENERGI-MIKRO-A",
|
||||
"local",
|
||||
docs_dir=str(bundle),
|
||||
bundle_dir=str(bundle),
|
||||
verdict_input=_VERDICT_INPUT,
|
||||
client_factory=_factory(),
|
||||
store=fresh_store,
|
||||
live_dry_run=True,
|
||||
)
|
||||
assert isinstance(report, DryRunReport)
|
||||
assert [s.target for s in report.skipped_links] == [_DANGLING]
|
||||
|
||||
|
||||
async def test_run_result_carries_the_trace(tmp_path, fresh_store) -> None:
|
||||
"""The full run too: navigation happens ONCE per run, before any proposal exists, so the trace
|
||||
is a RUN-level fact carried on ``RunResult`` — not on the per-proposal ``ProvenanceStamp``,
|
||||
which describes the gate that judged one candidate."""
|
||||
assert "skipped_links" in RunResult.__dataclass_fields__
|
||||
bundle = _bundle_copy(tmp_path, dangling=True)
|
||||
result = await run_project(
|
||||
"BYGG-ENERGI-MIKRO-A",
|
||||
"local",
|
||||
docs_dir=str(bundle),
|
||||
bundle_dir=str(bundle),
|
||||
verdict_input=_VERDICT_INPUT,
|
||||
client_factory=_factory(),
|
||||
store=fresh_store,
|
||||
)
|
||||
assert [s.target for s in result.skipped_links] == [_DANGLING]
|
||||
|
||||
|
||||
async def test_road_path_has_an_empty_trace(docs_dir, fresh_store) -> None:
|
||||
"""The road path navigates no bundle, so "nothing was skipped" is literally true there — which
|
||||
is exactly why the empty tuple is an honest DEFAULT rather than a withheld fact."""
|
||||
result = await run_project(
|
||||
"FV42-GSV-E1",
|
||||
"local",
|
||||
docs_dir=docs_dir,
|
||||
verdict_input=_VERDICT_INPUT,
|
||||
client_factory=_factory(
|
||||
json.dumps(
|
||||
{
|
||||
"measure": "Reduce scope",
|
||||
"affected_items": [{"code": "05.2", "quantity": 4300.0, "unit_cost": 215.0}],
|
||||
"claimed_saving_nok": 200000.0,
|
||||
}
|
||||
)
|
||||
),
|
||||
store=fresh_store,
|
||||
)
|
||||
assert result.skipped_links == ()
|
||||
|
||||
|
||||
def _dry_run_argv(bundle: Path) -> list[str]:
|
||||
return [
|
||||
"BYGG-ENERGI-MIKRO-A",
|
||||
"--docs-dir",
|
||||
str(bundle),
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--live-dry-run",
|
||||
]
|
||||
|
||||
|
||||
def test_cli_dry_run_announces_the_skipped_link(tmp_path, capsys) -> None:
|
||||
"""RED (the measured defect, verbatim): a dry run over a base with an unfollowable cross-link
|
||||
exited 0 with nothing said. It now names the document it never reached."""
|
||||
rc = run.main(_dry_run_argv(_bundle_copy(tmp_path, dangling=True)))
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert _SENTINEL in out
|
||||
assert _DANGLING in out
|
||||
|
||||
|
||||
def test_cli_dry_run_says_nothing_when_every_link_was_followed(tmp_path, capsys) -> None:
|
||||
"""Control: the same base with its links intact prints NO navigation line at all. A line for
|
||||
something the run does not have is omitted, never rendered blank."""
|
||||
rc = run.main(_dry_run_argv(_bundle_copy(tmp_path, dangling=False)))
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert _SENTINEL not in out
|
||||
assert "Knowledge base:" not in out
|
||||
|
||||
|
||||
def test_cli_full_run_announces_the_skipped_link(tmp_path, capsys) -> None:
|
||||
"""The full-run surface too, through the offline scripted door — so the notice is a property of
|
||||
a RUN, not of the dry-run branch alone. A run that PRODUCED a proposal from a half-read base is
|
||||
the case where the silence cost the most."""
|
||||
bundle = _bundle_copy(tmp_path, dangling=True)
|
||||
replies = tmp_path / "replies.json"
|
||||
replies.write_text(
|
||||
json.dumps({"proposer": _REPLY, "checker": "Holder. VERDICT: APPROVE"}), encoding="utf-8"
|
||||
)
|
||||
rc = run.main(
|
||||
[
|
||||
"BYGG-ENERGI-MIKRO-A",
|
||||
"--docs-dir",
|
||||
str(bundle),
|
||||
"--bundle-dir",
|
||||
str(bundle),
|
||||
"--scripted-replies",
|
||||
str(replies),
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert _SENTINEL in out
|
||||
assert _DANGLING in out
|
||||
Loading…
Add table
Add a link
Reference in a new issue