The context sets, the packaged knowledge bases and the example bundles are replaced by one fictitious example set about IT operations in an invented organisation: three context sets (serverrom-2027, driftsavtale-2027 and the two-base drift-og-avtale-2027), two synthetic knowledge bases under src/portfolio_optimiser/data/kunnskapsbaser and two example bundles under src/portfolio_optimiser/data/bundles. Numbers, codes and structural values in tests and fixtures are kept; names, ids and wording change. Dated measurement documents that only recorded runs on the replaced material are deleted. Gate figures measured on the new set are not comparable with earlier ones. The exclusion gate from the previous commit is green: 0 tracked files hit outside the shared/ subtree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
Python
283 lines
12 KiB
Python
"""P19 DEL B — a "cost code" must have a FORM when the input offers forms.
|
|
|
|
**The measured defect.** P18's round 2 ended with two ``validated`` proposals whose
|
|
``affected_item`` codes were ordinary words out of a standard's prose, in 4 of the 270 and 4 of
|
|
the 1 133 documents of the two bases they came from. This file names them by two stand-ins of the
|
|
same kind, ``nødstrømsaggregat`` and ``redundant kjøling``.
|
|
Both are GROUNDED in P7's sense — they appear verbatim in the input, which is all that stage asks —
|
|
and neither is INERT in P18/B1's sense, because neither is anywhere near the 5 % document share.
|
|
They are simply not identifiers of a cost line. The gate had no stage that could say so.
|
|
|
|
**Replayed offline against the very bases those runs were given** (the known positive, measured
|
|
15.09 rather than asserted): both come back ``Rejection`` naming the denominator —
|
|
``…offers 391 identifiers of its own`` / ``…offers 1359``.
|
|
|
|
**The generality guard is the half that makes this a rule rather than a preference.** The gate
|
|
fires ONLY where the input demonstrably offers identifier forms. A corpus that carries none cannot
|
|
be answered in a form it does not use, and refusing there would be a rule about SHAPES rather than
|
|
about grounding — exactly what ``_ground_against_input``'s own docstring refuses for the stage it
|
|
sits inside.
|
|
|
|
**B1 — the third and fourth forms, transcribed from a measurement.** A process catalogue's numbers
|
|
are bare dotted numbers (``12.1``, ``52.11``); ALL SIX ``ref`` values in
|
|
``contexts/driftsavtale-2027/fasit.json`` are of that shape, and NEITHER pre-P19 form matched one:
|
|
the catalogue measured then offered **3 identifiers over 6.5 MB**. Measured after: **2 332**. And
|
|
the first form was WIDENED in the same pass, because P19/B2 made these forms decide ``prose`` as
|
|
well as count an offer: this repo's own ``ENERGI-TOTAL-EL`` matched neither, so the classifier
|
|
called a real cost code prose and the new gate refused it. A gate may only be wrong in the direction
|
|
that admits too much.
|
|
|
|
**Two exemptions, both load-bearing:**
|
|
|
|
* a code the BASELINE carries is never refused here — stage 0 has already ruled it a real line of
|
|
this project, and the weaker stage must not overrule the stronger falsifier (the sentence
|
|
``_grounding_text`` already carries about its third source). A derived schedule whose codes are
|
|
bare section numbers would otherwise be refused wholesale by the gate meant to protect it;
|
|
* ``has_identifier_form`` FULL-matches. ``nødstrømsaggregat 12.1`` carrying a process number does
|
|
not make the word a cost code, and a substring rule would let any prose code smuggle one along.
|
|
|
|
What each arm pins:
|
|
|
|
(a) the two MEASURED defects are refused, with the denominator in the reason (Step 5 feeds that
|
|
reason verbatim into the next attempt, and "ungrounded" alone teaches nothing);
|
|
(b) the generality guard: a base offering no identifier form leaves the gate OFF;
|
|
(c) an anchored code is exempt — the stronger falsifier wins;
|
|
(d) the 18 known negatives: every ``ref`` in all three fasit files still classifies as an
|
|
identifier, and so does this repo's own ``ENERGI-TOTAL-EL``;
|
|
(e) B1's known positives and negatives, one by one;
|
|
(f) B2: the classification is reported, and it is the SAME classifier the gate uses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser.ir import CostBaseline, CostBaselineLine, SavingsProposal
|
|
from portfolio_optimiser.validator import (
|
|
Grounding,
|
|
Rejection,
|
|
ValidatedProposal,
|
|
classify_codes,
|
|
has_identifier_form,
|
|
identifier_tokens,
|
|
validate_proposal,
|
|
)
|
|
|
|
#: A base that OFFERS identifier forms: eleven documents carrying requirement numbers, one of which
|
|
#: also mentions the prose word. Eleven because ``_GROUNDING_MIN_INERT_DOCUMENTS`` is 10 — with
|
|
#: fewer, P18/B1's share rule cannot fire at all and this file would be measuring that instead.
|
|
_OFFERING = tuple(f"Krav 10.4.3—{i} om ventilasjon i serverrommet." for i in range(1, 12)) + (
|
|
"Krav 8.4.2—1 sier at nødstrømsaggregat skal dimensjoneres for full last.",
|
|
)
|
|
|
|
#: The same corpus with every identifier removed — prose only.
|
|
_FORMLESS = ("ingen koder her, bare tekst om ventilasjon", "og enda mer prosa om serverrommet")
|
|
|
|
|
|
def _proposal(code: str, *, quantity: float = 4.0, unit_cost: float = 250_000.0) -> SavingsProposal:
|
|
return SavingsProposal(
|
|
project_id="t",
|
|
measure="m",
|
|
affected_items=[{"code": code, "quantity": quantity, "unit_cost": unit_cost}],
|
|
claimed_saving_nok=100_000.0,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# (a) the measured defect
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_word_from_the_prose_is_not_a_cost_code() -> None:
|
|
"""(a) P18's ``nødstrømsaggregat``, in the shape that reached ``validated``."""
|
|
verdict = validate_proposal(_proposal("nødstrømsaggregat"), grounding=Grounding(_OFFERING))
|
|
assert isinstance(verdict, Rejection)
|
|
assert "has no identifier form" in verdict.reason
|
|
# The DENOMINATOR, not just a complaint: Step 5 feeds this reason into the next attempt.
|
|
assert "offers 14 identifiers of its own" in verdict.reason
|
|
|
|
|
|
def test_the_same_input_still_admits_a_real_reference() -> None:
|
|
"""(a), the other half. A gate that can only refuse proves nothing."""
|
|
assert isinstance(
|
|
validate_proposal(_proposal("Krav 8.4.2—1"), grounding=Grounding(_OFFERING)),
|
|
ValidatedProposal,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# (b)-(c) the two exemptions
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_base_that_offers_no_form_leaves_the_gate_off() -> None:
|
|
"""(b) The generality guard. A corpus with no identifiers cannot be answered in one."""
|
|
assert isinstance(
|
|
validate_proposal(_proposal("ventilasjon"), grounding=Grounding(_FORMLESS)),
|
|
ValidatedProposal,
|
|
)
|
|
|
|
|
|
def test_a_code_the_baseline_carries_is_never_refused_for_its_shape() -> None:
|
|
"""(c) Stage 0 has already ruled it a real line; the weaker stage must not overrule it."""
|
|
baseline = CostBaseline(
|
|
project_id="t",
|
|
items={"nødstrømsaggregat": CostBaselineLine(quantity=4.0, unit_cost=250_000.0)},
|
|
)
|
|
assert isinstance(
|
|
validate_proposal(
|
|
_proposal("nødstrømsaggregat"), baseline=baseline, grounding=Grounding(_OFFERING)
|
|
),
|
|
ValidatedProposal,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# (d)-(e) known negatives and positives
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_every_fasit_reference_in_every_context_set_is_an_identifier() -> None:
|
|
"""(d) Every fasit reference across every context set, with the denominator.
|
|
|
|
One of them — ``Krav 5.2.2—1_1`` — carries the optional ``_<n>`` suffix the second form grew
|
|
when a reference of that shape was measured as the single one the classifier called prose.
|
|
|
|
**The denominator is 18: six references in each of the three example sets**, and it is asserted
|
|
rather than dropped for the reason it was written down in the first place: a list comprehension
|
|
over ``contexts/*/fasit.json`` that quietly found fewer rows would make this arm weaker without
|
|
making it red. Ten are ``Krav x.y.z—n`` from driftskrav-2027 and eight are bare ``prosessnr``
|
|
from prosesskatalog-2027 (``12.1``, ``12.11``, ``12.12``, …) — the punctuation-and-digits form
|
|
B1 added, exercised by a fasit and not only by a known-positive.
|
|
"""
|
|
refs = [
|
|
concept["ref"]
|
|
for path in sorted(glob.glob("contexts/*/fasit.json"))
|
|
for row in json.loads(open(path, encoding="utf-8").read())["must_cite"]
|
|
for concept in row["concepts"]
|
|
]
|
|
assert len(refs) == 18, f"denominator moved: {len(refs)}"
|
|
assert [r for r in refs if not has_identifier_form(r)] == []
|
|
assert has_identifier_form("ENERGI-TOTAL-EL"), "this repo's own reference cost code"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"token",
|
|
[
|
|
"12.1",
|
|
"12.12",
|
|
"22.1",
|
|
"52.1",
|
|
"52.11",
|
|
"51.1",
|
|
"65 LAGRINGSSYSTEMER",
|
|
"SHA-01",
|
|
"B-20-00-00",
|
|
],
|
|
)
|
|
def test_b1_known_positives(token: str) -> None:
|
|
"""(e) The forms the delivered corpora carry."""
|
|
assert has_identifier_form(token)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"token",
|
|
[
|
|
"2027",
|
|
"250000",
|
|
"15.09.2026",
|
|
"2026-09-15",
|
|
"nødstrømsaggregat",
|
|
"redundant kjøling",
|
|
"0.70",
|
|
],
|
|
)
|
|
def test_b1_known_negatives(token: str) -> None:
|
|
"""(e) Bare numbers and dates are not identifiers — the K2-measured inert class."""
|
|
assert not identifier_tokens(token), f"{token!r} was counted as an identifier"
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# (f) B2 — the report
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_classification_is_reported_and_is_the_gates_own() -> None:
|
|
"""(f) One classifier, two consumers (kø-(p)): a report that disagreed with the gate about one
|
|
proposal would be evidence about nothing."""
|
|
forms = classify_codes(["nødstrømsaggregat", "Krav 8.4.2—1", "12.1", "ENERGI-TOTAL-EL"])
|
|
assert forms == {
|
|
"nødstrømsaggregat": "prose",
|
|
"Krav 8.4.2—1": "identifier",
|
|
"12.1": "identifier",
|
|
"ENERGI-TOTAL-EL": "identifier",
|
|
}
|
|
# And the gate agrees, code for code, on the SAME input.
|
|
for code, kind in forms.items():
|
|
verdict = validate_proposal(_proposal(code), grounding=Grounding(_OFFERING + (code,)))
|
|
refused = isinstance(verdict, Rejection) and "has no identifier form" in verdict.reason
|
|
assert refused == (kind == "prose"), (code, kind, verdict)
|
|
|
|
|
|
# --------------------------------------------------------------------------------------------
|
|
# P21 B3: ``rejection_stage`` — which falsifier wrote a reason. A REPORT, never a gate: nothing
|
|
# branches on it, so an unrecognised sentence costs a label rather than a verdict. It lives beside
|
|
# the sentences it keys on, so the classifier and the wordings cannot drift apart (kø-(p)).
|
|
# --------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_p21_rejection_stage_names_each_stage_from_its_own_sentence() -> None:
|
|
from portfolio_optimiser.validator import rejection_stage
|
|
|
|
assert (
|
|
rejection_stage("unknown cost code 'X': not in project p's cost baseline (3 known codes)")
|
|
== "stage0-baseline"
|
|
)
|
|
assert (
|
|
rejection_stage(
|
|
"quantity 5 for cost code 'X' is outside the 5.0% tolerance around the baseline "
|
|
"quantity 9"
|
|
)
|
|
== "stage0-baseline"
|
|
)
|
|
assert (
|
|
rejection_stage("ungrounded identifier 'X': it appears nowhere in the input (9 chars)")
|
|
== "stage0b-grounding"
|
|
)
|
|
assert rejection_stage("claimed saving 9 exceeds P90 feasible 4") == "stage4-p90"
|
|
assert (
|
|
rejection_stage("claimed saving 9 exceeds the nominal feasible 4 at the items' stated")
|
|
== "stage4b-nominal"
|
|
)
|
|
assert (
|
|
rejection_stage("claimed 9 exceeds the energy_efficiency method cap 4 (stricter)")
|
|
== "stage5-method-cap"
|
|
)
|
|
# The honest answer for a sentence this module did not write — a label, never a verdict.
|
|
assert rejection_stage("something else entirely") == "other"
|
|
|
|
|
|
def test_p21_rejection_stage_is_keyed_on_the_sentences_the_validator_emits() -> None:
|
|
"""The control: the markers are not a private paraphrase but the text stage 0 really writes.
|
|
|
|
Without it the classifier could key on wording nothing emits and every arm above would still
|
|
be green — the vacuous-gate class, on a reporter.
|
|
"""
|
|
from portfolio_optimiser.ir import AffectedItem, CostBaseline, CostBaselineLine, SavingsProposal
|
|
from portfolio_optimiser.validator import Rejection, rejection_stage, validate_proposal
|
|
|
|
baseline = CostBaseline(
|
|
project_id="proj", items={"REAL-1": CostBaselineLine(quantity=10.0, unit_cost=100.0)}
|
|
)
|
|
proposal = SavingsProposal(
|
|
project_id="proj",
|
|
measure="m",
|
|
affected_items=[AffectedItem(code="FAKE-1", quantity=10.0, unit_cost=100.0)],
|
|
claimed_saving_nok=100.0,
|
|
)
|
|
outcome = validate_proposal(proposal, baseline=baseline)
|
|
assert isinstance(outcome, Rejection)
|
|
assert rejection_stage(outcome.reason) == "stage0-baseline"
|