feat(okf): derive a cost baseline from a priced schedule, or refuse
MAJOR-4 (misjonsreview v2 section 7), owner B = the consumer. Neither existing projection into ir.CostBaseline can serve an ingested tender corpus: cost-baseline.json is hand-written per project and baseline_from_project belongs to the road domain, so a K2-shaped bundle could be navigated and never anchored. okf.derive_cost_baseline reads the numbers already in the bundle. The premise was MEASURED before anything was built on it, and the order's two pointers named two different forms. examples/*/expected-bundle/ carry pipe tables, but every one of them is csv- or sql-sourced via render.render_table -- the xlsx path never reaches render_table at all. Measured with pandoc 3.10.2 under the producer's own writer and arguments: extract._extract_office converts, and inbox.py hands that text to render_inbox_concept untouched, so an xlsx-sourced concept file carries a pandoc SIMPLE table whose dash rule defines the column spans. A pipe-only reader would have been inert on exactly the corpus this exists for. Both forms are read, by two scanners over one role mapping and one number grammar. No judgement anywhere: the header vocabulary and the number grammar are closed, and every ambiguity refuses -- no candidate table, more than one, two columns claiming one role, two rows sharing a cost code, a row that prices nothing. A partly-priced schedule refuses in full, because a half-derived baseline anchors some codes while cost_baseline_anchored reports True. Wired behind --derive-cost-baseline and never silently: one resolution in run.py's bundle arm serves both the full run and the dry run, and the refusal propagates rather than degrading to the file loader. The two fixtures are pandoc's output verbatim, not hand-typed. The unpriced one is K2's actual pre-award shape, and the columns survive as blanks -- so the table IS a candidate and the refusal is the sharp one. Load-bearing MEASURED: 18 mutations all red against the WHOLE suite, green control 1230 passed / 5 skipped (from 1208/5, superset, 0 removed), golden demo-transcript.stdout byte-unchanged (ea8c534773acdbe41ae68f2c55724d69aaf8be4f). M17 is the one that matters for arm (b): dropping the positivity guard makes the mutant raise pydantic ValidationError, which IS a ValueError but is NOT the named class -- so pytest.raises(ValueError) would have stayed green against exactly the mutation the arm exists to catch. Verified directly, not argued. Honesty limits stated in the invariant row: NS 3451 section rows are not classified (K2 itself was not available to measure), the stamp records that a run was anchored and never which projection anchored it, the hosted surface is deliberately untouched, and no live K2 file was read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9e44afeefb
commit
0add73531b
11 changed files with 870 additions and 7 deletions
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -1408,6 +1408,71 @@ Python ≥3.10. MAF (`agent-framework-core` 1.16.0, `-orchestrations` 1.1.1 —
|
||||||
parameteren har aldri vært øvet, og det er RAPPORTERT, ikke fikset (utenfor denne ordren);
|
parameteren har aldri vært øvet, og det er RAPPORTERT, ikke fikset (utenfor denne ordren);
|
||||||
`verified_field` validerer ikke `at`; og at en LEVENDE modell kaller falsifiseringsskillen godt er
|
`verified_field` validerer ikke `at`; og at en LEVENDE modell kaller falsifiseringsskillen godt er
|
||||||
ikke bevist (structured-output-grensens klasse).
|
ikke bevist (structured-output-grensens klasse).
|
||||||
|
- **Den TREDJE projeksjonen inn i `CostBaseline` DERIVERES fra en tabell som alt er i basen — og
|
||||||
|
den nekter heller enn å oppfinne (MAJOR-4, økt 78):** `cost-baseline.json` er håndskrevet per
|
||||||
|
prosjekt og `baseline_from_project` tilhører veg-domenet, så et INGESTERT anbudskorpus kunne
|
||||||
|
navigeres og aldri forankres. `okf.derive_cost_baseline(bundle, *, project_id)` leser
|
||||||
|
prisskjemaet. **`project_id` er PÅKREVD keyword, ikke lest av basen:** `run._project_from_bundle`
|
||||||
|
fail-faster alt kjøringens id mot basens `validator-input.json`, så en andre lesing her ville vært
|
||||||
|
kø-(p) — og ville dratt en IR-projeksjon inn i en funksjon hvis hele input er en tabell.
|
||||||
|
**PREMISSET BLE MÅLT FØR NOE BLE BYGGET PÅ DET, og ordrens to pekere navnga TO ULIKE FORMER:**
|
||||||
|
`examples/*/expected-bundle/` bærer PIPE-tabeller, men hver eneste av dem er csv-/sql-kilt og
|
||||||
|
rendret av `render.render_table` — **xlsx-stien når ALDRI `render_table`.** Målt (pandoc 3.10.2,
|
||||||
|
produsentens egen `_PANDOC_WRITER = "markdown"` + `_PANDOC_ARGS = ("--eol=lf", "--wrap=none")`,
|
||||||
|
mot produsentens egen `tests/fixtures/two-line-krav.xlsx` og et håndlagt Prisskjema):
|
||||||
|
`extract._extract_office` konverterer, og `inbox.py` gir teksten videre til
|
||||||
|
`render_inbox_concept(sanitized_text, …)` URØRT — resultatet er en pandoc **SIMPLE table**, der
|
||||||
|
dash-linja definerer kolonne-spennene. En pipe-only leser ville altså vært INERT på nøyaktig det
|
||||||
|
korpuset MAJOR-4 finnes for, og en simple-only leser inert på eksemplene ordren pekte på. Begge
|
||||||
|
leses, av TO skannere over ÉN rollemapping og ÉN tallgrammatikk (`_split_frontmatter`-formen: ett
|
||||||
|
skann, to lesere), aldri to kopier av semantikken. **Span-slicing, ALDRI splitt på whitespace:**
|
||||||
|
K2s hele form er tomme priseceller, og en whitespace-splitt kollapser dem og skyver hver senere
|
||||||
|
verdi én kolonne til venstre — en feilmapping som leses som DATA (M16 → 3 røde).
|
||||||
|
**Ingen skjønn noe sted:** header-vokabularet er lukket og matches EKSAKT (en delstrengregel ville
|
||||||
|
latt `Enhetspris eks. mva` og `… inkl. mva` kreve samme rolle, og leseren ville valgt mellom to
|
||||||
|
priser), tallgrammatikken er lukket og **dot-desimal** (målt output er `1250.0`/`42.5`, så et
|
||||||
|
komma ville kjøpt ingenting og importert `1,250`s tusenskille-vs-desimal-tvetydighet gratis — et
|
||||||
|
håndskrevet norsk `1 250,50` er en NEKTET celle, uttalt), og hver tvetydighet er en nekt: ingen
|
||||||
|
kandidat-tabell, mer enn én, to kolonner om samme rolle, to rader med samme kostkode.
|
||||||
|
**Delvis priset skjema nekter i SIN HELHET** — ikke forsiktighet: en halvderivert base forankrer
|
||||||
|
noen koder mens `ProvenanceStamp.cost_baseline_anchored` rapporterer `True`, og den biten er
|
||||||
|
påkrevd-uten-default nettopp fordi begge defaults ville løyet. Skannet over `context_files`, aldri
|
||||||
|
`files` (MAJOR-3-regelen). **`CostBaselineDerivationError` subklasser `ValueError`**
|
||||||
|
(`BundleIdMismatch`-presedensen) — CLI-ens nekt-tuppel og hostings 400-arm, aldri krasj-kanalen.
|
||||||
|
Wiret bak `--derive-cost-baseline`, ALDRI stille: ÉN oppløsning i `run.py`s bundle-arm tjener både
|
||||||
|
full kjøring og `live_dry_run`, og **nekten PROPAGERER** i stedet for å degradere til fil-lasteren
|
||||||
|
(`load_mandate`-regelen — en kaller som ba om derivasjon og fikk en uforankret kjøring er besvart
|
||||||
|
av en stille nedgradert ordre). Tre CLI-nekter, alle ved NAVN: krever `--bundle-dir`, refusert i
|
||||||
|
`--portfolio` (ved navn, ikke ved gjennomfall til `--bundle-dir`-kravet — `--explore`-presedensen)
|
||||||
|
og i `report_forbidden` (report-modus returnerer FØR nekten, så en utelatelse er et stille DROPP,
|
||||||
|
ikke en nekt — F4-gapet). **FIXTUREN er MÅLT, ikke håndskrevet:** begge basene under
|
||||||
|
`tests/fixtures/` er pandocs output ORDRETT, og den uprisede er K2s faktiske pre-award-form —
|
||||||
|
**kolonnene OVERLEVER som blanke** (målt), så tabellen ER en kandidat og nekten blir den skarpe:
|
||||||
|
leseren identifiserer kolonnene, leser tomme celler og nekter ved navn. `validator-input.json`
|
||||||
|
skrives KUN i testen, aldri inn i den innsjekkede fixturen (`measure`/`claimed_saving_nok` er
|
||||||
|
menneskets/mandatets). **`pytest.raises(ValueError)` ville IKKE gatet arm (b), og det er MÅLT, ikke
|
||||||
|
resonnert:** M17 (drop positivitetsvakten) får mutanten til å reise
|
||||||
|
`pydantic_core.ValidationError` — `isinstance(e, ValueError) is True`,
|
||||||
|
`isinstance(e, CostBaselineDerivationError) is False` — så en løsere arm ville stått grønn mot
|
||||||
|
nøyaktig den mutasjonen den finnes for. Load-bearing MÅLT
|
||||||
|
(`tests/test_cost_baseline_derivation_loadbearing.py`, 22 armer), **18 mutasjoner alle røde mot
|
||||||
|
HELE suiten** + grønn kontroll **1230 passed / 5 skipped** (fra 1208/5, supersett, 0 fjernet) og
|
||||||
|
golden `demo-transcript.stdout` BYTE-UENDRET (`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`):
|
||||||
|
M1 detach run-wiringen (2 røde) · M2 fallback ved nekt (1) · M3 hopp over uprisede rader (2) ·
|
||||||
|
M4 fabrikkér null (1) · M4b fabrikkér null uten tom-celle-nekten (1) · M17 drop
|
||||||
|
positivitetsvakten (1) · M5 transponer mengde/enhetspris (3) · M6 skann `files` (1) · M7 ta første
|
||||||
|
tabell ved tvetydighet (1) · M8 ta første kolonne ved duplisert rolle (1) · M9 last-write-wins på
|
||||||
|
duplisert kode (1) · M10 utvid tallgrammatikken (1) · M11 drop simple-skanneren (3) · M12 drop
|
||||||
|
pipe-skanneren (7) · M13 detach `--bundle-dir`-nekten (1) · M14 drop fra `report_forbidden` (1) ·
|
||||||
|
M15 drop fra portefølje-partisjonen (1) · M16 whitespace-splitt (3). **Ærlighets-grenser, uttalt:**
|
||||||
|
NS 3451 seksjonsrader (kode + overskrift, ingen mengde, inne i et ellers priset ark) er IKKE
|
||||||
|
klassifisert — K2 selv var ikke tilgjengelig å måle her, så et slikt ark NEKTER, og regelen skrives
|
||||||
|
når noen kan måle den ekte formen (en rad som er tom over alle tre kolonner er derimot en blank
|
||||||
|
spacer og hoppes over); stempelet sier AT en kjøring var forankret, aldri HVILKEN av de tre
|
||||||
|
projeksjonene forankret den; den hostede flaten er BEVISST urørt (feltet er ikke i whitelisten, så
|
||||||
|
4e-halvdelene står uendret); og ingen LEVENDE K2-fil er lest — fixturen er syntetisk, og at
|
||||||
|
produsenten faktisk emitterer denne formen for K2s Prisskjema er MÅLT på pandoc-stien, ikke på K2.
|
||||||
|
|
||||||
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
||||||
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
||||||
|
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -421,7 +421,9 @@ when the seam is detached, so the loop cannot silently degrade into theater.
|
||||||
[Walk the whole chain offline](#walk-the-whole-chain-offline); mutually exclusive with
|
[Walk the whole chain offline](#walk-the-whole-chain-offline); mutually exclusive with
|
||||||
`--live-dry-run`, which stops before the first model call rather than answering it), and
|
`--live-dry-run`, which stops before the first model call rather than answering it), and
|
||||||
`--explore "<prompt>" --explore-config <file>` (opt-in: run an exploration first and let it
|
`--explore "<prompt>" --explore-config <file>` (opt-in: run an exploration first and let it
|
||||||
shape the mandate this run evaluates — see below).
|
shape the mandate this run evaluates — see below), and `--derive-cost-baseline` (opt-in:
|
||||||
|
anchor the validator on a priced schedule already inside `--bundle-dir` instead of a
|
||||||
|
hand-written `cost-baseline.json` — see below).
|
||||||
- **Portfolio** — `--portfolio`, plus optional `--goals`, `--ledger`, `--dimension-config`,
|
- **Portfolio** — `--portfolio`, plus optional `--goals`, `--ledger`, `--dimension-config`,
|
||||||
`--semantic-retrieval`; it stops early and prints a `goal reached: …` line when the
|
`--semantic-retrieval`; it stops early and prints a `goal reached: …` line when the
|
||||||
accumulated ledger meets a goal.
|
accumulated ledger meets a goal.
|
||||||
|
|
@ -509,6 +511,30 @@ when the seam is detached, so the loop cannot silently degrade into theater.
|
||||||
`--checkpoint-dir` are refused together (two doors onto one review), as are `--resume` and
|
`--checkpoint-dir` are refused together (two doors onto one review), as are `--resume` and
|
||||||
`--explore` (two sources of one exploration).
|
`--explore` (two sources of one exploration).
|
||||||
|
|
||||||
|
- **Deriving the cost baseline from the knowledge base** — `--derive-cost-baseline` (opt-in,
|
||||||
|
requires `--bundle-dir`). The deterministic validator anchors a proposal's `affected_items` to
|
||||||
|
the project's actual cost lines. Normally those come from a hand-written `cost-baseline.json` in
|
||||||
|
the bundle, or — on the road reference path — from the project's own `cost_items`. Neither is
|
||||||
|
available when the bundle was *ingested* from tender documents, so this third route reads the
|
||||||
|
priced schedule that is already in the base: one markdown table whose header names a cost code, a
|
||||||
|
quantity and a unit price. Both table forms the producer emits are read — the pipe tables its
|
||||||
|
csv/sql connectors render, and the pandoc simple tables an `.xlsx` becomes.
|
||||||
|
|
||||||
|
**It refuses rather than guesses, and that is the point.** A pre-award schedule has its price
|
||||||
|
column empty, and completing it with a zero or an invented number would hand the gate a baseline
|
||||||
|
nobody wrote. So the run stops — by name — when no table names all three columns, when more than
|
||||||
|
one does, when two columns claim the same role, when two rows share a cost code, or when any row
|
||||||
|
prices nothing. A partly-priced schedule is refused in full: a half-derived baseline would anchor
|
||||||
|
some codes while the run still reported itself anchored. The header vocabulary and the number
|
||||||
|
grammar are both closed (plain dot-decimal numbers; a thousands separator or a comma decimal is
|
||||||
|
refused, not interpreted).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m portfolio_optimiser.run PROSJEKT-1 --docs-dir <bundle> --bundle-dir <bundle> \
|
||||||
|
--derive-cost-baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
`--explore` is refused together with `--mandate` — they are two sources of one mandate, and
|
`--explore` is refused together with `--mandate` — they are two sources of one mandate, and
|
||||||
merging would silently overwrite what you wrote. To seed an exploration with a domain expert's
|
merging would silently overwrite what you wrote. To seed an exploration with a domain expert's
|
||||||
own hypotheses, use `explore(..., seed_approaches=[Approach(...)])`; seeds are always preserved
|
own hypotheses, use `explore(..., seed_approaches=[Approach(...)])`; seeds are always preserved
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,13 @@ class CostBaseline(BaseModel):
|
||||||
"""A project's cost baseline, keyed by cost code — the ground truth the deterministic
|
"""A project's cost baseline, keyed by cost code — the ground truth the deterministic
|
||||||
validator anchors ``affected_items`` to, so the gate cannot be fed hallucinated cost lines.
|
validator anchors ``affected_items`` to, so the gate cannot be fed hallucinated cost lines.
|
||||||
|
|
||||||
Deliberately a typed IR contract (not a loader-private shape): both sources project INTO
|
Deliberately a typed IR contract (not a loader-private shape): THREE sources project INTO
|
||||||
it — an OKF bundle's ``cost-baseline.json`` (``okf.load_cost_baseline``) and the road
|
it — an OKF bundle's hand-written ``cost-baseline.json`` (``okf.load_cost_baseline``), the road
|
||||||
reference domain's ``cost_items`` (``validator.baseline_from_project``) — so the validator
|
reference domain's ``cost_items`` (``validator.baseline_from_project``), and a priced schedule
|
||||||
sees ONE representation regardless of path, and the Claude-SDK sibling can mirror it (D7).
|
DERIVED from a table already in the bundle (``okf.derive_cost_baseline``, MAJOR-4) — so the
|
||||||
|
validator sees ONE representation regardless of path, and the Claude-SDK sibling can mirror it
|
||||||
|
(D7). The third exists because neither of the first two can serve an INGESTED tender corpus:
|
||||||
|
one is hand-written per project, the other belongs to the road domain.
|
||||||
|
|
||||||
The projection/tolerance semantics were decided HERE: the commons amendment specifying
|
The projection/tolerance semantics were decided HERE: the commons amendment specifying
|
||||||
``cost-baseline.json`` never arrived, exactly as in S3.2. D7 mirroring stays OPEN.
|
``cost-baseline.json`` never arrived, exactly as in S3.2. D7 mirroring stays OPEN.
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Final, Literal
|
from typing import Any, Final, Literal
|
||||||
|
|
||||||
from portfolio_optimiser.ir import CostBaseline
|
from portfolio_optimiser.ir import CostBaseline, CostBaselineLine
|
||||||
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
|
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
|
||||||
|
|
||||||
_INDEX_NAME = "index.md"
|
_INDEX_NAME = "index.md"
|
||||||
|
|
@ -1113,6 +1113,270 @@ def load_optional_cost_baseline(bundle_dir: str, name: str = _COST_BASELINE) ->
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CostBaselineDerivationError(ValueError):
|
||||||
|
"""A priced schedule could not be read into a ``CostBaseline`` WITHOUT judgement (MAJOR-4).
|
||||||
|
|
||||||
|
A ``ValueError`` subclass on purpose, the ``BundleIdMismatch`` precedent: it lands on
|
||||||
|
``run.main``'s refusal tuple and the hosted surface's 400 arm rather than on the crash channel,
|
||||||
|
because a schedule this reader cannot map is a caller's input being wrong, not the framework
|
||||||
|
failing.
|
||||||
|
|
||||||
|
Every raise here is VALIDATION, never repair. That is the whole point of the third projection:
|
||||||
|
K2 is pre-award, so its ``Prisskjema`` carries codes and descriptions with the price column
|
||||||
|
empty, and the one thing a deriver must never do is answer that with a zero or an invented unit
|
||||||
|
cost. A baseline is the deterministic gate's ground truth — an invented line would let the gate
|
||||||
|
reconcile a hallucinated proposal against a hallucinated baseline and report itself anchored."""
|
||||||
|
|
||||||
|
|
||||||
|
#: Header text -> the role it fills, as a CLOSED vocabulary. Matching is on the lower-cased,
|
||||||
|
#: stripped header cell and is EXACT: a substring rule would let ``Enhetspris eks. mva`` and
|
||||||
|
#: ``Enhetspris inkl. mva`` both claim the same role and the reader would be choosing between two
|
||||||
|
#: prices, which is exactly the judgement this function may not exercise. An unrecognised header is
|
||||||
|
#: simply not a role, and a table missing any role is not a candidate.
|
||||||
|
_BASELINE_ROLES: Final[dict[str, tuple[str, ...]]] = {
|
||||||
|
"code": ("postnr", "post", "kode", "kostkode", "code", "cost_code"),
|
||||||
|
"quantity": ("mengde", "antall", "quantity"),
|
||||||
|
"unit_cost": ("enhetspris", "unit_cost", "unit price", "unit_price"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The number grammar, and it is CLOSED: optional sign, digits, optional dot-decimal. Measured
|
||||||
|
#: (pandoc 3.10.2, the producer's own writer and arguments) the xlsx path emits ``1250.0`` / ``42.5``
|
||||||
|
#: — no thousands separators and no comma decimals — so admitting a comma would buy nothing and
|
||||||
|
#: import ``1,250``'s thousands-versus-decimal ambiguity for free. A hand-authored Norwegian
|
||||||
|
#: ``1 250,50`` is therefore a REFUSED cell, by name, rather than a guess.
|
||||||
|
_BASELINE_NUMBER_RE: Final = re.compile(r"-?\d+(?:\.\d+)?")
|
||||||
|
|
||||||
|
#: A pandoc SIMPLE table's dash rule: two or more dash groups, whose spans define the columns.
|
||||||
|
_SIMPLE_RULE_RE: Final = re.compile(r" *-+(?: +-+)+ *")
|
||||||
|
#: A pipe table's separator row cell, e.g. ``---`` or ``:---:``.
|
||||||
|
_PIPE_RULE_CELL_RE: Final = re.compile(r":?-+:?")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ScheduleTable:
|
||||||
|
"""One markdown table found in a concept file: its header cells, its data rows, and the
|
||||||
|
bundle-relative file it was read from (so a refusal can name the document)."""
|
||||||
|
|
||||||
|
file: str
|
||||||
|
header: tuple[str, ...]
|
||||||
|
rows: tuple[tuple[str, ...], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_pipe_row(line: str) -> tuple[str, ...]:
|
||||||
|
"""Split one pipe-table row on UNESCAPED ``|``. ``render.render_table`` escapes ``\\`` first and
|
||||||
|
then ``|``, so the reader has to undo them in the opposite order or a cell containing a literal
|
||||||
|
backslash comes back wrong."""
|
||||||
|
cells, current, escaped = [], [], False
|
||||||
|
for char in line.strip().strip("|"):
|
||||||
|
if escaped:
|
||||||
|
current.append(char)
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == "|":
|
||||||
|
cells.append("".join(current).strip())
|
||||||
|
current = []
|
||||||
|
else:
|
||||||
|
current.append(char)
|
||||||
|
cells.append("".join(current).strip())
|
||||||
|
return tuple(cells)
|
||||||
|
|
||||||
|
|
||||||
|
def _pipe_tables(file: str, body: str) -> list[_ScheduleTable]:
|
||||||
|
"""The csv/sql-sourced form (``render.render_table``): ``| a | b |`` over ``| --- | --- |``."""
|
||||||
|
tables, lines = [], body.split("\n")
|
||||||
|
for i in range(len(lines) - 1):
|
||||||
|
header_line, rule_line = lines[i].strip(), lines[i + 1].strip()
|
||||||
|
if not header_line.startswith("|") or not rule_line.startswith("|"):
|
||||||
|
continue
|
||||||
|
rule = _split_pipe_row(rule_line)
|
||||||
|
if not rule or not all(_PIPE_RULE_CELL_RE.fullmatch(cell) for cell in rule):
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for line in lines[i + 2 :]:
|
||||||
|
if not line.strip().startswith("|"):
|
||||||
|
break
|
||||||
|
rows.append(_split_pipe_row(line))
|
||||||
|
tables.append(
|
||||||
|
_ScheduleTable(file=file, header=_split_pipe_row(header_line), rows=tuple(rows))
|
||||||
|
)
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_tables(file: str, body: str) -> list[_ScheduleTable]:
|
||||||
|
"""The xlsx form. MEASURED, not assumed: ``extract._extract_office`` converts through pandoc
|
||||||
|
with ``_PANDOC_WRITER = "markdown"`` and ``_PANDOC_ARGS = ("--eol=lf", "--wrap=none")``, and
|
||||||
|
``inbox.py`` hands that text to ``render_inbox_concept`` untouched — it never reaches
|
||||||
|
``render_table``. The result is a pandoc SIMPLE table, and its dash rule is the column
|
||||||
|
definition: each dash group's span slices the same columns out of the header and every row.
|
||||||
|
|
||||||
|
Span-slicing rather than splitting on whitespace runs is what makes an EMPTY cell readable. K2's
|
||||||
|
whole shape is empty price cells, and a whitespace split would collapse them and silently shift
|
||||||
|
every later value one column left — a mis-mapping that reads as data rather than as a defect."""
|
||||||
|
tables, lines = [], body.split("\n")
|
||||||
|
for i in range(1, len(lines)):
|
||||||
|
if not _SIMPLE_RULE_RE.fullmatch(lines[i]) or not lines[i - 1].strip():
|
||||||
|
continue
|
||||||
|
spans = [(m.start(), m.end()) for m in re.finditer(r"-+", lines[i])]
|
||||||
|
rows = []
|
||||||
|
for line in lines[i + 1 :]:
|
||||||
|
if not line.strip():
|
||||||
|
break
|
||||||
|
rows.append(tuple(line[a:b].strip() for a, b in spans))
|
||||||
|
tables.append(
|
||||||
|
_ScheduleTable(
|
||||||
|
file=file,
|
||||||
|
header=tuple(lines[i - 1][a:b].strip() for a, b in spans),
|
||||||
|
rows=tuple(rows),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def _role_columns(table: _ScheduleTable) -> dict[str, list[int]] | None:
|
||||||
|
"""Map each role to the column indices claiming it, or ``None`` when the table is not a
|
||||||
|
candidate at all (some role is named by no column)."""
|
||||||
|
found: dict[str, list[int]] = {role: [] for role in _BASELINE_ROLES}
|
||||||
|
for index, cell in enumerate(table.header):
|
||||||
|
for role, names in _BASELINE_ROLES.items():
|
||||||
|
if cell.strip().lower() in names:
|
||||||
|
found[role].append(index)
|
||||||
|
return None if any(not columns for columns in found.values()) else found
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(row: tuple[str, ...], index: int) -> str:
|
||||||
|
"""A row shorter than its header has an EMPTY cell there, never a missing one: a truncated row
|
||||||
|
is exactly how an unfilled trailing column arrives, and it must reach the same refusal."""
|
||||||
|
return row[index].strip() if index < len(row) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _number(raw: str, *, header: str, code: str) -> float:
|
||||||
|
if not _BASELINE_NUMBER_RE.fullmatch(raw):
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"row {code!r}: column {header!r} holds {raw!r}, which is not a plain decimal number "
|
||||||
|
"(the grammar is optional sign, digits, optional dot-decimal — a thousands separator "
|
||||||
|
"or a comma decimal is refused rather than guessed at)"
|
||||||
|
)
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_cost_baseline(bundle: Bundle, *, project_id: str) -> CostBaseline:
|
||||||
|
"""Derive a ``CostBaseline`` from a priced schedule the producer already rendered into a concept
|
||||||
|
file — the THIRD projection into the type whose docstring names the other two (MAJOR-4).
|
||||||
|
|
||||||
|
Neither existing projection can serve an ingested tender corpus: ``cost-baseline.json`` is
|
||||||
|
hand-written per project and ``validator.baseline_from_project`` belongs to the road reference
|
||||||
|
domain. So a K2-shaped bundle could be navigated and never anchored. This reads the numbers that
|
||||||
|
are already IN the bundle.
|
||||||
|
|
||||||
|
**``project_id`` is a REQUIRED keyword rather than something read from the bundle.** The bundle's
|
||||||
|
``validator-input.json`` carries one, but ``run._project_from_bundle`` already fail-fasts the
|
||||||
|
run's requested id against it — reading it a second time here would make this a second reader of
|
||||||
|
a fact that has an owner (kø-(p)), and would drag an IR projection into a function whose whole
|
||||||
|
input is a table.
|
||||||
|
|
||||||
|
**NO judgement anywhere.** The header vocabulary is closed and matched exactly, the number
|
||||||
|
grammar is closed, and every ambiguity is a refusal rather than a choice:
|
||||||
|
|
||||||
|
* no table in the bundle names all three roles, or more than one does;
|
||||||
|
* two columns of one table claim the same role;
|
||||||
|
* two rows carry the same cost code (a dict would last-write-win, and the baseline would then
|
||||||
|
describe one of two lines the operator can see in the document);
|
||||||
|
* a row prices nothing — empty, unparseable, or a non-positive unit cost.
|
||||||
|
|
||||||
|
**A partly-priced schedule refuses in FULL.** Not conservatism: a half-derived baseline anchors
|
||||||
|
some codes while ``ProvenanceStamp.cost_baseline_anchored`` reports ``True``, and that bit is
|
||||||
|
required-without-default precisely because both of its defaults would lie.
|
||||||
|
|
||||||
|
Scanned over ``context_files``, never ``files`` (MAJOR-3's rule): a ``type: verdict`` file is a
|
||||||
|
prior judgement, not project cost data, and a reader over ``files`` would let one decide a
|
||||||
|
project's ground truth outside the gated ExpeL fold.
|
||||||
|
|
||||||
|
**Honesty limits, stated.** A row that is empty across all three mapped columns asserts nothing
|
||||||
|
and is skipped — that is a blank spacer, not a price. But NS 3451 section rows (a code and a
|
||||||
|
heading with no quantity, inside an otherwise priced sheet) are NOT classified, because K2 itself
|
||||||
|
was not available to measure here; such a sheet refuses, and the rule for it should be written
|
||||||
|
when someone can measure the real form. And the provenance stamp records THAT a run was
|
||||||
|
anchored, never WHICH of the three projections anchored it.
|
||||||
|
|
||||||
|
Gated by ``tests/test_cost_baseline_derivation_loadbearing.py``."""
|
||||||
|
tables: list[tuple[_ScheduleTable, dict[str, list[int]]]] = []
|
||||||
|
for concept in bundle.context_files:
|
||||||
|
for table in _pipe_tables(concept.name, concept.body) + _simple_tables(
|
||||||
|
concept.name, concept.body
|
||||||
|
):
|
||||||
|
roles = _role_columns(table)
|
||||||
|
if roles is not None:
|
||||||
|
tables.append((table, roles))
|
||||||
|
|
||||||
|
if not tables:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"no cost table found in bundle {bundle.dir!r}: no concept file carries a markdown "
|
||||||
|
f"table whose header names all three of {sorted(_BASELINE_ROLES)} "
|
||||||
|
f"(recognised headers: {_BASELINE_ROLES})"
|
||||||
|
)
|
||||||
|
if len(tables) > 1:
|
||||||
|
named = ", ".join(sorted({table.file for table, _ in tables}))
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{len(tables)} cost tables found in bundle {bundle.dir!r} ({named}); which one prices "
|
||||||
|
"the project is a question about the documents, not one this reader answers by order "
|
||||||
|
"of appearance"
|
||||||
|
)
|
||||||
|
|
||||||
|
table, roles = tables[0]
|
||||||
|
for role, columns in roles.items():
|
||||||
|
if len(columns) > 1:
|
||||||
|
duplicated = ", ".join(repr(table.header[index]) for index in columns)
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: {len(columns)} columns claim the {role!r} role ({duplicated}); "
|
||||||
|
"a reader that took the first would be choosing between them"
|
||||||
|
)
|
||||||
|
code_at, quantity_at, unit_cost_at = (
|
||||||
|
roles[role][0] for role in ("code", "quantity", "unit_cost")
|
||||||
|
)
|
||||||
|
|
||||||
|
items: dict[str, CostBaselineLine] = {}
|
||||||
|
for row in table.rows:
|
||||||
|
code = _cell(row, code_at)
|
||||||
|
quantity_raw = _cell(row, quantity_at)
|
||||||
|
unit_cost_raw = _cell(row, unit_cost_at)
|
||||||
|
if not code and not quantity_raw and not unit_cost_raw:
|
||||||
|
continue # a blank spacer row asserts nothing
|
||||||
|
if not code:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: a row carries numbers under an empty {table.header[code_at]!r} "
|
||||||
|
"cell, so the line it prices cannot be named"
|
||||||
|
)
|
||||||
|
for raw, index in ((quantity_raw, quantity_at), (unit_cost_raw, unit_cost_at)):
|
||||||
|
if not raw:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: row {code!r} has an empty {table.header[index]!r} cell. This "
|
||||||
|
"schedule is not priced (K2's pre-award shape); a baseline is refused rather "
|
||||||
|
"than completed with a value nobody wrote"
|
||||||
|
)
|
||||||
|
quantity = _number(quantity_raw, header=table.header[quantity_at], code=code)
|
||||||
|
unit_cost = _number(unit_cost_raw, header=table.header[unit_cost_at], code=code)
|
||||||
|
if unit_cost <= 0:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: row {code!r} has {table.header[unit_cost_at]!r} = {unit_cost}, "
|
||||||
|
"which prices nothing; a baseline line must carry a positive unit cost"
|
||||||
|
)
|
||||||
|
if quantity < 0:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: row {code!r} has {table.header[quantity_at]!r} = {quantity}"
|
||||||
|
)
|
||||||
|
if code in items:
|
||||||
|
raise CostBaselineDerivationError(
|
||||||
|
f"{table.file}: cost code {code!r} appears twice; keeping either row would make "
|
||||||
|
"the baseline describe one of two lines the document shows"
|
||||||
|
)
|
||||||
|
items[code] = CostBaselineLine(quantity=quantity, unit_cost=unit_cost)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise CostBaselineDerivationError(f"{table.file}: the cost table has no priced rows at all")
|
||||||
|
return CostBaseline(project_id=project_id, items=items)
|
||||||
|
|
||||||
|
|
||||||
def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str, Any]:
|
def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str, Any]:
|
||||||
"""Load the bundle's IR projection (``validator-input.json`` by default): the candidate
|
"""Load the bundle's IR projection (``validator-input.json`` by default): the candidate
|
||||||
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the
|
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,11 @@ async def run_project(
|
||||||
docs_dir: str,
|
docs_dir: str,
|
||||||
verdict_input: dict[str, str] | None = None,
|
verdict_input: dict[str, str] | None = None,
|
||||||
bundle_dir: str | None = None,
|
bundle_dir: str | None = None,
|
||||||
|
#: Derive the validator's cost baseline from a priced schedule IN the bundle
|
||||||
|
#: (``okf.derive_cost_baseline``) instead of loading a hand-written ``cost-baseline.json``.
|
||||||
|
#: Bundle path only, and OPT-IN by construction: the default leaves every existing run on the
|
||||||
|
#: file loader, byte-identically.
|
||||||
|
derive_cost_baseline: bool = False,
|
||||||
dimension: Dimension | None = None,
|
dimension: Dimension | None = None,
|
||||||
store: VerdictStore | None = None,
|
store: VerdictStore | None = None,
|
||||||
verdict_dir: str | None = None,
|
verdict_dir: str | None = None,
|
||||||
|
|
@ -691,7 +696,17 @@ async def run_project(
|
||||||
okf.reconcile_bundle_id(bundle_dir)
|
okf.reconcile_bundle_id(bundle_dir)
|
||||||
bundle = okf.navigate_bundle(bundle_dir)
|
bundle = okf.navigate_bundle(bundle_dir)
|
||||||
project = _project_from_bundle(bundle_dir, project_id, bundle=bundle)
|
project = _project_from_bundle(bundle_dir, project_id, bundle=bundle)
|
||||||
baseline = okf.load_optional_cost_baseline(bundle_dir)
|
# The THIRD projection into ``CostBaseline`` (MAJOR-4), behind an EXPLICIT commission and
|
||||||
|
# never silent. The refusal PROPAGATES rather than degrading to the file loader: a caller
|
||||||
|
# who asked for derivation and got an un-anchored run instead would have been answered by a
|
||||||
|
# silently downgraded order, which is what ``load_mandate`` fail-fasts against. This one
|
||||||
|
# resolution serves BOTH the full run and the ``live_dry_run`` report below, so the dry-run
|
||||||
|
# arm cannot drift away from what a real run would anchor on.
|
||||||
|
baseline = (
|
||||||
|
okf.derive_cost_baseline(bundle, project_id=project_id)
|
||||||
|
if derive_cost_baseline
|
||||||
|
else okf.load_optional_cost_baseline(bundle_dir)
|
||||||
|
)
|
||||||
# §4.1a context-scope: agents read ONLY dimension-scoped bundle knowledge (Step-3 filter);
|
# §4.1a context-scope: agents read ONLY dimension-scoped bundle knowledge (Step-3 filter);
|
||||||
# dimension=None keeps the full context, byte-identical to before.
|
# dimension=None keeps the full context, byte-identical to before.
|
||||||
context = okf.bundle_context(bundle, dimension=dimension.id if dimension else None)
|
context = okf.bundle_context(bundle, dimension=dimension.id if dimension else None)
|
||||||
|
|
@ -1956,6 +1971,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--derive-cost-baseline",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"derive the validator's cost baseline from a priced schedule inside --bundle-dir "
|
||||||
|
"instead of loading a hand-written cost-baseline.json (MAJOR-4). Refuses rather than "
|
||||||
|
"guesses: an unpriced or ambiguous schedule stops the run"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--scripted-replies",
|
"--scripted-replies",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2033,6 +2057,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
report_forbidden = {
|
report_forbidden = {
|
||||||
"--portfolio": args.portfolio,
|
"--portfolio": args.portfolio,
|
||||||
"--live-dry-run": args.live_dry_run,
|
"--live-dry-run": args.live_dry_run,
|
||||||
|
# Report mode returns before the run dispatch, so an omission here is a SILENT DROP,
|
||||||
|
# not a refusal — the gap F4 measured on --plan-review.
|
||||||
|
"--derive-cost-baseline": args.derive_cost_baseline,
|
||||||
"PROJECT_ID": args.project_id is not None,
|
"PROJECT_ID": args.project_id is not None,
|
||||||
"--goals": args.goals is not None,
|
"--goals": args.goals is not None,
|
||||||
"--docs-dir": args.docs_dir is not None,
|
"--docs-dir": args.docs_dir is not None,
|
||||||
|
|
@ -2101,6 +2128,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"--outbox-dir": args.outbox_dir,
|
"--outbox-dir": args.outbox_dir,
|
||||||
"--run-id": args.run_id,
|
"--run-id": args.run_id,
|
||||||
"--live-dry-run": args.live_dry_run,
|
"--live-dry-run": args.live_dry_run,
|
||||||
|
# BY NAME, not by falling through to "--derive-cost-baseline requires --bundle-dir":
|
||||||
|
# --bundle-dir is already single-project-only, so that message would tell an operator
|
||||||
|
# who wrote --portfolio --derive-cost-baseline to add the one flag this mode also
|
||||||
|
# refuses. Same reason --explore is listed here rather than left to fall through.
|
||||||
|
"--derive-cost-baseline": args.derive_cost_baseline,
|
||||||
# One exploration shapes ONE mandate against ONE knowledge base, and --bundle-dir (its
|
# One exploration shapes ONE mandate against ONE knowledge base, and --bundle-dir (its
|
||||||
# only source of bases here) is already single-project-only. Refusing it by NAME beats
|
# only source of bases here) is already single-project-only. Refusing it by NAME beats
|
||||||
# letting it fall through to the --bundle-dir requirement below: an operator who wrote
|
# letting it fall through to the --bundle-dir requirement below: an operator who wrote
|
||||||
|
|
@ -2149,6 +2181,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# The third projection reads a table INSIDE a bundle, so without one there is nothing to derive
|
||||||
|
# from: the road path's baseline comes from ``Project.cost_items`` and is anchored by
|
||||||
|
# construction. Refused by NAME here rather than left to surface later as a project-lookup
|
||||||
|
# failure, which names neither the flag nor what it needs.
|
||||||
|
if not args.portfolio and args.derive_cost_baseline and args.bundle_dir is None:
|
||||||
|
print(
|
||||||
|
"run refused: --derive-cost-baseline requires --bundle-dir (the schedule it derives "
|
||||||
|
"from is a concept file in the knowledge base; the road path is already anchored by "
|
||||||
|
"its own cost_items)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
# --semantic-retrieval is refused, never silently ignored (the repo's flag contract). In
|
# --semantic-retrieval is refused, never silently ignored (the repo's flag contract). In
|
||||||
# single-project mode it can only do observable work with BOTH of these: the Step-1 fold is
|
# single-project mode it can only do observable work with BOTH of these: the Step-1 fold is
|
||||||
# gated on ``bundle_dir``, and ``--verdict-dir`` is the only route by which ``main()`` can hand
|
# gated on ``bundle_dir``, and ``--verdict-dir`` is the only route by which ``main()`` can hand
|
||||||
|
|
@ -2761,6 +2806,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
outbox_dir=args.outbox_dir,
|
outbox_dir=args.outbox_dir,
|
||||||
run_id=args.run_id,
|
run_id=args.run_id,
|
||||||
verdict_input=_verdict_input_from_args(args),
|
verdict_input=_verdict_input_from_args(args),
|
||||||
|
derive_cost_baseline=args.derive_cost_baseline,
|
||||||
mcp_servers=mcp_servers,
|
mcp_servers=mcp_servers,
|
||||||
live_dry_run=True,
|
live_dry_run=True,
|
||||||
)
|
)
|
||||||
|
|
@ -2820,6 +2866,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
run_id=args.run_id,
|
run_id=args.run_id,
|
||||||
verdict_input=_verdict_input_from_args(args),
|
verdict_input=_verdict_input_from_args(args),
|
||||||
semantic_retrieval=args.semantic_retrieval,
|
semantic_retrieval=args.semantic_retrieval,
|
||||||
|
derive_cost_baseline=args.derive_cost_baseline,
|
||||||
client_factory=scripted_client_factory,
|
client_factory=scripted_client_factory,
|
||||||
mandate=mandate,
|
mandate=mandate,
|
||||||
mcp_servers=mcp_servers,
|
mcp_servers=mcp_servers,
|
||||||
|
|
|
||||||
31
tests/fixtures/README.md
vendored
Normal file
31
tests/fixtures/README.md
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Test fixtures
|
||||||
|
|
||||||
|
## `k2-prisskjema-SYNTETISK/` and `k2-prisskjema-uprisert-SYNTETISK/`
|
||||||
|
|
||||||
|
**SYNTETISK — every number in these bundles is invented.** They copy the *structure* of a
|
||||||
|
public-tender `Prisskjema.xlsx` (NS 3451 posts: code, description, unit, quantity, unit price,
|
||||||
|
sum) and nothing else. **No private tender document is reproduced here**, and the unit prices in
|
||||||
|
the priced variant are round synthetic values chosen to be obviously not a real bid — they must
|
||||||
|
never be read as anyone's pricing. This is the first building block of the S7b corpus.
|
||||||
|
|
||||||
|
The two variants are the two halves of the same measurement:
|
||||||
|
|
||||||
|
| bundle | `Enhetspris` column | what it stands for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `k2-prisskjema-SYNTETISK/` | filled | a priced schedule a baseline CAN be derived from |
|
||||||
|
| `k2-prisskjema-uprisert-SYNTETISK/` | empty | K2's actual pre-award state — codes and descriptions, no quantities or prices |
|
||||||
|
|
||||||
|
### The table form is MEASURED, not hand-typed
|
||||||
|
|
||||||
|
Both bodies are the **verbatim output** of the producer's own office conversion, so the fixture
|
||||||
|
states something about the format rather than recording our own idea of it:
|
||||||
|
|
||||||
|
pandoc -f xlsx -t markdown --eol=lf --wrap=none <sheet>.xlsx
|
||||||
|
|
||||||
|
with `pandoc 3.10.2` — the writer and the two arguments are `llm_ingestion_okf.extract`'s
|
||||||
|
`_PANDOC_WRITER` / `_PANDOC_ARGS`. That is a pandoc **simple table** (a dash rule whose groups
|
||||||
|
define the column spans), NOT the pipe table `render_table` emits: measured in session 78, the
|
||||||
|
xlsx path reaches a concept body through `inbox.py`'s `render_inbox_concept(sanitized_text, …)`
|
||||||
|
and never touches `render_table` at all. An empty spreadsheet cell therefore arrives as blank
|
||||||
|
space inside its column span — which is exactly what the unpriced variant has to carry for the
|
||||||
|
refusal it gates to be the real one.
|
||||||
8
tests/fixtures/k2-prisskjema-SYNTETISK/index.md
vendored
Normal file
8
tests/fixtures/k2-prisskjema-SYNTETISK/index.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
type: index
|
||||||
|
title: K2 Prisskjema (SYNTETISK, priset)
|
||||||
|
---
|
||||||
|
|
||||||
|
# K2 Prisskjema (SYNTETISK)
|
||||||
|
|
||||||
|
Syntetisk priset prisskjema. Se [prisskjema-SYNTETISK.md](prisskjema-SYNTETISK.md).
|
||||||
12
tests/fixtures/k2-prisskjema-SYNTETISK/prisskjema-SYNTETISK.md
vendored
Normal file
12
tests/fixtures/k2-prisskjema-SYNTETISK/prisskjema-SYNTETISK.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
type: dataset
|
||||||
|
title: Prisskjema (SYNTETISK, priset)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prisskjema {#sheet-1}
|
||||||
|
|
||||||
|
Postnr Beskrivelse Enhet Mengde Enhetspris Sum
|
||||||
|
-------- ---------------------------------------------------- ------- --------- ------------ -----------
|
||||||
|
21.1 Grunnarbeider, utgraving og bortkjoering av masser m3 1250.0 850.0 1062500.0
|
||||||
|
24.2 Baerekonstruksjon i staal, levert og montert kg 48000.0 42.5 2040000.0
|
||||||
|
36.1 Ventilasjonsaggregat med varmegjenvinning stk 4.0 187500.0 750000.0
|
||||||
9
tests/fixtures/k2-prisskjema-uprisert-SYNTETISK/index.md
vendored
Normal file
9
tests/fixtures/k2-prisskjema-uprisert-SYNTETISK/index.md
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
type: index
|
||||||
|
title: K2 Prisskjema (SYNTETISK, upriset)
|
||||||
|
---
|
||||||
|
|
||||||
|
# K2 Prisskjema (SYNTETISK, upriset)
|
||||||
|
|
||||||
|
Syntetisk gjengivelse av K2s pre-award-form. Se
|
||||||
|
[prisskjema-uprisert-SYNTETISK.md](prisskjema-uprisert-SYNTETISK.md).
|
||||||
12
tests/fixtures/k2-prisskjema-uprisert-SYNTETISK/prisskjema-uprisert-SYNTETISK.md
vendored
Normal file
12
tests/fixtures/k2-prisskjema-uprisert-SYNTETISK/prisskjema-uprisert-SYNTETISK.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
type: dataset
|
||||||
|
title: Prisskjema (SYNTETISK, upriset)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prisskjema {#sheet-1}
|
||||||
|
|
||||||
|
Postnr Beskrivelse Enhet Mengde Enhetspris Sum
|
||||||
|
-------- ---------------------------------------------------- ------- -------- ------------ -----
|
||||||
|
21.1 Grunnarbeider, utgraving og bortkjoering av masser m3
|
||||||
|
24.2 Baerekonstruksjon i staal, levert og montert kg
|
||||||
|
36.1 Ventilasjonsaggregat med varmegjenvinning stk
|
||||||
386
tests/test_cost_baseline_derivation_loadbearing.py
Normal file
386
tests/test_cost_baseline_derivation_loadbearing.py
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"""``okf.derive_cost_baseline`` — the THIRD projection into ``ir.CostBaseline``, and the one that
|
||||||
|
must refuse rather than invent (MAJOR-4, misjonsreview v2 §7; owner B = the consumer, PM decision
|
||||||
|
2026-09-02).
|
||||||
|
|
||||||
|
``ir.CostBaseline``'s docstring names two projections INTO it — a bundle's hand-written
|
||||||
|
``cost-baseline.json`` (``okf.load_cost_baseline``) and the road reference domain's ``cost_items``
|
||||||
|
(``validator.baseline_from_project``). Neither can be produced from an ingested tender corpus, so
|
||||||
|
K2 could be navigated but never RUN: ``run_project``'s bundle arm resolves an OPTIONAL baseline and
|
||||||
|
a bundle carrying only ingested documents is simply un-anchored. This adds the third: derive the
|
||||||
|
baseline from a priced schedule the producer already rendered into a concept file.
|
||||||
|
|
||||||
|
**The premise was measured before anything was built on it, and the order's two pointers named two
|
||||||
|
DIFFERENT forms.** The order says "markdown-tabell rendret av produsenten fra xlsx via pandoc" and
|
||||||
|
also "les examples/ i llm-ingestion-okf for formen":
|
||||||
|
|
||||||
|
* ``examples/*/expected-bundle/`` carry **pipe** tables — but every one of them is csv- or
|
||||||
|
sql-sourced, rendered by ``render.render_table``.
|
||||||
|
* The **xlsx** path never reaches ``render_table``. ``extract._extract_office`` converts through
|
||||||
|
``pypandoc`` with ``_PANDOC_WRITER = "markdown"`` and ``_PANDOC_ARGS = ("--eol=lf",
|
||||||
|
"--wrap=none")``, and ``inbox.py`` hands that text straight to
|
||||||
|
``render_inbox_concept(decision.sanitized_text, …)``. Measured with pandoc 3.10.2 against the
|
||||||
|
producer's own ``tests/fixtures/two-line-krav.xlsx`` and against a hand-laid Prisskjema: the
|
||||||
|
result is a pandoc **simple table**, whose dash rule defines the column spans.
|
||||||
|
|
||||||
|
So a pipe-only reader would have been INERT on exactly the corpus MAJOR-4 exists for, and a
|
||||||
|
simple-only reader would be inert on the examples the order pointed at. Both forms are read, by two
|
||||||
|
scanners over ONE role mapping and ONE number grammar — the ``_split_frontmatter`` shape (one
|
||||||
|
scan, two readers), never two copies of the semantics.
|
||||||
|
|
||||||
|
**Arm (b) is the point of the whole thing.** K2 is pre-award: measured by the PM agent on
|
||||||
|
2026-09-02, ``Prisskjema.xlsx`` has 0/28 unit-price cells filled and the NS 3451 posts 01–92 carry
|
||||||
|
codes and descriptions with no quantities or prices. The columns still exist — measured here: an
|
||||||
|
empty spreadsheet cell arrives as blank space inside its span, so the header row still names
|
||||||
|
``Mengde`` and ``Enhetspris`` and the table IS a candidate. That is what makes the refusal sharp:
|
||||||
|
the deriver identifies the columns, reads empty cells, and must refuse BY NAME rather than emit a
|
||||||
|
``CostBaseline`` with an invented or zero ``unit_cost``.
|
||||||
|
|
||||||
|
``pytest.raises(ValueError)`` would NOT have gated that. ``pydantic.ValidationError`` subclasses
|
||||||
|
``ValueError`` and ``CostBaselineLine.unit_cost`` is ``Field(gt=0)``, so a deriver that fabricated
|
||||||
|
``0.0`` would raise a ``ValueError`` too and the arm would stay green against the single mutation
|
||||||
|
it exists to catch. Every refusal arm asserts the NAMED class and the token that distinguishes it.
|
||||||
|
|
||||||
|
The fixtures' numbers are chosen so a role-swap is visible: no row has ``quantity ==
|
||||||
|
unit_cost`` and the two magnitudes differ per row (kø-(y)'s ``observed != limit`` rule — a fixture
|
||||||
|
where they coincide cannot tell a correct mapping from a transposed one).
|
||||||
|
|
||||||
|
Gates ``okf.derive_cost_baseline`` + ``run.run_project(derive_cost_baseline=…)`` + the CLI door.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import okf, run
|
||||||
|
from portfolio_optimiser.ir import CostBaseline
|
||||||
|
|
||||||
|
_FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
_PRICED = str(_FIXTURES / "k2-prisskjema-SYNTETISK")
|
||||||
|
_UNPRICED = str(_FIXTURES / "k2-prisskjema-uprisert-SYNTETISK")
|
||||||
|
|
||||||
|
#: What the priced fixture's table says, transcribed from the fixture rather than from the
|
||||||
|
#: spreadsheet it was rendered from: arm (a) asserts the derivation equals the TABLE.
|
||||||
|
_PRICED_ROWS = {
|
||||||
|
"21.1": (1250.0, 850.0),
|
||||||
|
"24.2": (48000.0, 42.5),
|
||||||
|
"36.1": (4.0, 187500.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle(path: str) -> okf.Bundle:
|
||||||
|
return okf.navigate_bundle(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bundle(root: Path, body: str, *, name: str = "table.md") -> str:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "index.md").write_text(
|
||||||
|
f"---\ntype: index\ntitle: T\n---\n\n[t]({name})\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root / name).write_text(f"---\ntype: dataset\ntitle: T\n---\n\n{body}", encoding="utf-8")
|
||||||
|
return str(root)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# ARM (b) FIRST — the K2 form must refuse, typed and by name.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_b_unpriced_k2_form_refuses_by_name() -> None:
|
||||||
|
"""Codes and descriptions, EMPTY quantity and price columns: the refusal, not a baseline."""
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError) as excinfo:
|
||||||
|
okf.derive_cost_baseline(_bundle(_UNPRICED), project_id="K2")
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "empty" in message.lower()
|
||||||
|
# Names the row AND the column: "which post" and "which cell" are two operative questions.
|
||||||
|
assert "21.1" in message
|
||||||
|
assert "Enhetspris" in message or "Mengde" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_b_the_refusal_is_the_named_class_not_a_bare_value_error() -> None:
|
||||||
|
"""``pytest.raises(ValueError)`` cannot gate this arm: ``pydantic.ValidationError`` IS a
|
||||||
|
``ValueError``, so a deriver fabricating ``unit_cost=0.0`` would satisfy it. The named class is
|
||||||
|
what separates 'we refused' from 'pydantic caught our invention on the way out'."""
|
||||||
|
assert issubclass(okf.CostBaselineDerivationError, ValueError)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError):
|
||||||
|
okf.derive_cost_baseline(_bundle(_UNPRICED), project_id="K2")
|
||||||
|
# And the refusal is OURS, raised before any CostBaseline is constructed.
|
||||||
|
try:
|
||||||
|
okf.derive_cost_baseline(_bundle(_UNPRICED), project_id="K2")
|
||||||
|
except okf.CostBaselineDerivationError as exc:
|
||||||
|
assert type(exc).__name__ == "CostBaselineDerivationError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_b_a_zero_unit_cost_is_refused_by_name_never_carried(tmp_path: Path) -> None:
|
||||||
|
"""A cell that PARSES but prices nothing. ``CostBaselineLine`` would reject it anyway — the
|
||||||
|
point is that the refusal names the column instead of surfacing as a schema error about a
|
||||||
|
number we chose to pass on."""
|
||||||
|
body = "| Postnr | Mengde | Enhetspris |\n| --- | --- | --- |\n| 21.1 | 1250 | 0 |\n"
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="Enhetspris"):
|
||||||
|
okf.derive_cost_baseline(_bundle(_write_bundle(tmp_path / "zero", body)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
def test_b_partial_pricing_refuses_the_WHOLE_derivation(tmp_path: Path) -> None:
|
||||||
|
"""One priced row and one unpriced row is not "a baseline with one line". A half-derived
|
||||||
|
baseline anchors some codes while ``ProvenanceStamp.cost_baseline_anchored`` reports ``True`` —
|
||||||
|
a false positive on the one bit made REQUIRED-without-default precisely because both of its
|
||||||
|
defaults would lie."""
|
||||||
|
body = (
|
||||||
|
"| Postnr | Mengde | Enhetspris |\n"
|
||||||
|
"| --- | --- | --- |\n"
|
||||||
|
"| 21.1 | 1250 | 850 |\n"
|
||||||
|
"| 24.2 | 48000 | |\n"
|
||||||
|
)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="24.2"):
|
||||||
|
okf.derive_cost_baseline(_bundle(_write_bundle(tmp_path / "half", body)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# ARM (a) — a priced table derives, and derives what the table SAYS.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_priced_pandoc_table_derives_the_table_verbatim() -> None:
|
||||||
|
baseline = okf.derive_cost_baseline(_bundle(_PRICED), project_id="K2")
|
||||||
|
assert isinstance(baseline, CostBaseline)
|
||||||
|
assert baseline.project_id == "K2"
|
||||||
|
assert set(baseline.items) == set(_PRICED_ROWS)
|
||||||
|
for code, (quantity, unit_cost) in _PRICED_ROWS.items():
|
||||||
|
assert baseline.items[code].quantity == quantity, code
|
||||||
|
assert baseline.items[code].unit_cost == unit_cost, code
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_no_row_has_quantity_equal_to_unit_cost() -> None:
|
||||||
|
"""The fixture's own discriminating property, asserted rather than assumed: if any row had
|
||||||
|
``quantity == unit_cost``, the arm above would pass against a deriver that transposed the two
|
||||||
|
columns, and the fixture would be gating nothing."""
|
||||||
|
for code, (quantity, unit_cost) in _PRICED_ROWS.items():
|
||||||
|
assert quantity != unit_cost, code
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_the_pipe_table_form_derives_identically(tmp_path: Path) -> None:
|
||||||
|
"""The csv/sql-sourced form (``render.render_table``) reads through the SAME role mapping."""
|
||||||
|
body = (
|
||||||
|
"| Postnr | Beskrivelse | Mengde | Enhetspris |\n"
|
||||||
|
"| --- | --- | --- | --- |\n"
|
||||||
|
"| 21.1 | Grunnarbeider | 1250 | 850 |\n"
|
||||||
|
"| 24.2 | Staal | 48000 | 42.5 |\n"
|
||||||
|
)
|
||||||
|
baseline = okf.derive_cost_baseline(
|
||||||
|
_bundle(_write_bundle(tmp_path / "pipe", body)), project_id="P"
|
||||||
|
)
|
||||||
|
assert baseline.items["21.1"].quantity == 1250.0
|
||||||
|
assert baseline.items["21.1"].unit_cost == 850.0
|
||||||
|
assert baseline.items["24.2"].unit_cost == 42.5
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# Determinism: no judgement. Where the columns cannot be identified UNAMBIGUOUSLY, refuse.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_identifiable_table_refuses(tmp_path: Path) -> None:
|
||||||
|
body = "| Fritekst | Notat |\n| --- | --- |\n| a | b |\n"
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="no"):
|
||||||
|
okf.derive_cost_baseline(_bundle(_write_bundle(tmp_path / "none", body)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_candidate_tables_refuse_rather_than_pick(tmp_path: Path) -> None:
|
||||||
|
"""Two priced schedules in one bundle is a DATA question, never one the reader answers by
|
||||||
|
order of appearance (``scripted_proposer``'s ambiguity rule)."""
|
||||||
|
root = tmp_path / "two"
|
||||||
|
body = "| Postnr | Mengde | Enhetspris |\n| --- | --- | --- |\n| 21.1 | 1250 | 850 |\n"
|
||||||
|
_write_bundle(root, body, name="a.md")
|
||||||
|
(root / "b.md").write_text(
|
||||||
|
f"---\ntype: dataset\ntitle: B\n---\n\n{body}",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "index.md").write_text(
|
||||||
|
"---\ntype: index\ntitle: T\n---\n\n[a](a.md)\n[b](b.md)\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError) as excinfo:
|
||||||
|
okf.derive_cost_baseline(_bundle(str(root)), project_id="P")
|
||||||
|
assert "a.md" in str(excinfo.value) and "b.md" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_duplicated_role_in_one_header_refuses(tmp_path: Path) -> None:
|
||||||
|
"""Two columns claiming the same role: a reader that took the first would be choosing."""
|
||||||
|
body = (
|
||||||
|
"| Postnr | Mengde | Enhetspris | Enhetspris |\n"
|
||||||
|
"| --- | --- | --- | --- |\n"
|
||||||
|
"| 21.1 | 1250 | 850 | 900 |\n"
|
||||||
|
)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="Enhetspris"):
|
||||||
|
okf.derive_cost_baseline(_bundle(_write_bundle(tmp_path / "dup", body)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_duplicated_cost_code_refuses(tmp_path: Path) -> None:
|
||||||
|
"""A dict would silently last-write-win, and the baseline would quietly describe one of two
|
||||||
|
rows an operator can see in the document."""
|
||||||
|
body = (
|
||||||
|
"| Postnr | Mengde | Enhetspris |\n"
|
||||||
|
"| --- | --- | --- |\n"
|
||||||
|
"| 21.1 | 1250 | 850 |\n"
|
||||||
|
"| 21.1 | 20 | 30 |\n"
|
||||||
|
)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="21.1"):
|
||||||
|
okf.derive_cost_baseline(_bundle(_write_bundle(tmp_path / "dupcode", body)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_number_outside_the_grammar_refuses(tmp_path: Path) -> None:
|
||||||
|
"""The grammar is dot-decimal, and it is closed. The measured pandoc output emits ``1250.0`` /
|
||||||
|
``42.5``, so no comma handling is needed — and admitting one would import ``1,250``'s
|
||||||
|
thousands-versus-decimal ambiguity for free, which is exactly the judgement this function is
|
||||||
|
forbidden to exercise. A hand-authored Norwegian ``1 250,50`` is a REFUSED case, stated."""
|
||||||
|
for cell in ("1 250", "850,50", "850 kr", "n/a"):
|
||||||
|
body = f"| Postnr | Mengde | Enhetspris |\n| --- | --- | --- |\n| 21.1 | 1250 | {cell} |\n"
|
||||||
|
root = _write_bundle(tmp_path / f"num-{abs(hash(cell))}", body)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError, match="Enhetspris"):
|
||||||
|
okf.derive_cost_baseline(_bundle(root), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_verdict_layer_is_not_scanned(tmp_path: Path) -> None:
|
||||||
|
"""Built from ``context_files``, never ``files`` (MAJOR-3's rule): a ``type: verdict`` file is
|
||||||
|
not project cost data, and a reader over ``files`` would let a prior verdict's table decide a
|
||||||
|
project's ground truth."""
|
||||||
|
root = tmp_path / "verdict"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "index.md").write_text(
|
||||||
|
"---\ntype: index\ntitle: T\n---\n\n[v](v.md)\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root / "v.md").write_text(
|
||||||
|
"---\ntype: verdict\ntitle: V\n---\n\n"
|
||||||
|
"| Postnr | Mengde | Enhetspris |\n| --- | --- | --- |\n| 21.1 | 1250 | 850 |\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError):
|
||||||
|
okf.derive_cost_baseline(_bundle(str(root)), project_id="P")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# CONTROL — the two existing projections are untouched, and the file remains PREFERRED.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_a_bundle_shipping_a_handwritten_baseline_is_untouched() -> None:
|
||||||
|
"""``load_optional_cost_baseline`` still answers, unchanged, and remains what the run path
|
||||||
|
reaches for when the flag is absent."""
|
||||||
|
shipped = okf.load_optional_cost_baseline("shared/examples/tunnel-hauglia")
|
||||||
|
assert shipped is not None
|
||||||
|
assert shipped.project_id == "TUNNEL-HAUGLIA"
|
||||||
|
assert "ENERGI-TUNNEL-EL" in shipped.items
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_the_fixture_bundles_ship_no_cost_baseline_json() -> None:
|
||||||
|
"""Otherwise arm (a) could be satisfied by the file loader and prove nothing about derivation."""
|
||||||
|
for path in (_PRICED, _UNPRICED):
|
||||||
|
assert not (Path(path) / "cost-baseline.json").exists()
|
||||||
|
assert okf.load_optional_cost_baseline(path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_the_fixtures_are_marked_synthetic() -> None:
|
||||||
|
readme = (_FIXTURES / "README.md").read_text(encoding="utf-8")
|
||||||
|
assert "SYNTETISK" in readme
|
||||||
|
assert "SYNTETISK" in Path(_PRICED).name
|
||||||
|
assert "SYNTETISK" in Path(_UNPRICED).name
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
# WIRING — the third projection is reachable only behind the explicit flag, never silently.
|
||||||
|
# --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: The IR projection ``_project_from_bundle`` fail-fasts on. Written HERE, in the test, and never
|
||||||
|
#: into the checked-in fixture: ``measure`` and ``claimed_saving_nok`` are the human's or the
|
||||||
|
#: mandate's to state, so a SYNTETISK K2 bundle carrying one would look like a runnable tender
|
||||||
|
#: whose candidate measure somebody authored. The derivation reads the priced TABLE; this file only
|
||||||
|
#: satisfies the pre-existing bundle contract so the wiring can be exercised at all.
|
||||||
|
_IR_PROJECTION = {
|
||||||
|
"project_id": "K2",
|
||||||
|
"measure": "PLACEHOLDER — authored by this test purely to satisfy the bundle contract",
|
||||||
|
"affected_items": [{"code": "21.1", "quantity": 1250, "unit_cost": 850.0}],
|
||||||
|
"claimed_saving_nok": 1000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _runnable(source: str, tmp_path: Path) -> str:
|
||||||
|
"""A copy of a fixture bundle plus the hand-authored IR projection the bundle arm requires."""
|
||||||
|
root = tmp_path / "runnable"
|
||||||
|
shutil.copytree(source, root)
|
||||||
|
(root / "validator-input.json").write_text(json.dumps(_IR_PROJECTION), encoding="utf-8")
|
||||||
|
return str(root)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_project_uses_the_derived_baseline_when_the_flag_is_given(tmp_path: Path) -> None:
|
||||||
|
bundle_dir = _runnable(_PRICED, tmp_path)
|
||||||
|
report = await run.run_project(
|
||||||
|
"K2",
|
||||||
|
"local",
|
||||||
|
docs_dir=bundle_dir,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
derive_cost_baseline=True,
|
||||||
|
live_dry_run=True,
|
||||||
|
)
|
||||||
|
assert isinstance(report, run.DryRunReport)
|
||||||
|
assert report.cost_baseline_anchored is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_project_without_the_flag_leaves_the_fixture_unanchored(tmp_path: Path) -> None:
|
||||||
|
"""The default is the unchanged path: no ``cost-baseline.json``, so no anchoring — and the
|
||||||
|
derivation is NOT reached. Silence never derives."""
|
||||||
|
bundle_dir = _runnable(_PRICED, tmp_path)
|
||||||
|
report = await run.run_project(
|
||||||
|
"K2", "local", docs_dir=bundle_dir, bundle_dir=bundle_dir, live_dry_run=True
|
||||||
|
)
|
||||||
|
assert isinstance(report, run.DryRunReport)
|
||||||
|
assert report.cost_baseline_anchored is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_project_propagates_the_refusal_rather_than_falling_back(tmp_path: Path) -> None:
|
||||||
|
"""The flag is an explicit commission. A derivation that fails must not quietly degrade to the
|
||||||
|
(absent) file and report an un-anchored run: a run that goes ahead on a silently degraded
|
||||||
|
order is the defect ``load_mandate`` fail-fasts against."""
|
||||||
|
bundle_dir = _runnable(_UNPRICED, tmp_path)
|
||||||
|
with pytest.raises(okf.CostBaselineDerivationError):
|
||||||
|
await run.run_project(
|
||||||
|
"K2",
|
||||||
|
"local",
|
||||||
|
docs_dir=bundle_dir,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
derive_cost_baseline=True,
|
||||||
|
live_dry_run=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_derive_requires_bundle_dir(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
rc = run.main(["P1", "--docs-dir", "docs", "--derive-cost-baseline"])
|
||||||
|
assert rc == 1
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
# Keyed on the DISTINGUISHING token: every refusal below also contains the flag name.
|
||||||
|
assert "--bundle-dir" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_derive_is_refused_in_portfolio_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
rc = run.main(["--portfolio", "--derive-cost-baseline"])
|
||||||
|
assert rc == 1
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "--portfolio" in err
|
||||||
|
assert "single-project mode" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_derive_is_refused_in_report_mode(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Report mode returns BEFORE every downstream refusal, so a flag missing from
|
||||||
|
``report_forbidden`` is silently DROPPED rather than refused (F4 found exactly that gap). The
|
||||||
|
argv is one report mode would otherwise ACCEPT, so rc 1 is the mutant's opposite outcome."""
|
||||||
|
ledger = tmp_path / "ledger.json"
|
||||||
|
ledger.write_text(json.dumps([]), encoding="utf-8")
|
||||||
|
assert run.main(["--report", "--ledger", str(ledger)]) == 0
|
||||||
|
capsys.readouterr()
|
||||||
|
rc = run.main(["--report", "--ledger", str(ledger), "--derive-cost-baseline"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "mode-exclusive" in capsys.readouterr().err
|
||||||
Loading…
Add table
Add a link
Reference in a new issue